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.
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
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
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 about managing low-level states 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.
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.
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 that added 10% to your contribution in Rust, it would look something like this:
This way, my LiquidityEnhancer would work with any pool that 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 that links to exact pool implementations would have to be published for any new pool implementation.
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 that 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.
Sui has support for
First, seamless and transparent code updates are not possible. If I have a package that 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.
Sui recently released a
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 the possibility 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 that 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 that 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.
Sui’s VM is optimized to program for, and execute on, a 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.
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 that requires a price from a specific Oracle in one of its functions. In Rust, this may look like this:
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
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 to make sure the reference has been updated to the latest state.
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
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 of 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.
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 that anyone can access, lending protocols where anyone can borrow and lend, oracles that 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).