# VEP-19: Implement Synchronous Function Calls to Vite Contracts
# Background
# Composability
Composability is one of the core features of a decentralized ecosystem, that is, one smart contract can interact with others in unlimited combinations. So that developers can stack smart contracts on top of one another like Lego.
To achieve composability, smart contract programming languages must be designed to make it easier for contracts to talk to each other.
Solidity on EVM makes contracts inherently composable through synchronous external function calls and the ABI specification.
But for Solidity++ on Vite, things are not that simple. Because Vite is an asynchronous blockchain, calls to smart contracts on Vite are also asynchronous, that is, the return value cannot be obtained immediately after the call. Therefore, the composability can only be achieved through the callback functions in the early version of Solidity++ (before 0.8.1).
# Composability Using Callbacks
Achieving composability through callbacks is rather painful for developers.
Let's start with a simple example:
- Contract
A
has anadd()
method that returns the sum of two unsigned integers. - Contract
B
has atest()
method that calculates the sum of1
and2
by callingA.add()
, and stores the result in the storage variabledata
.
Straightforward, the implementation in Solidity is as follows:
It can be implemented using callbacks in Solidity++ 0.8.0 as follows:
We add an additional parameter callback
of type function(uint) external
to A.add()
.
When contract B
calls contract A
, it needs to pass the callback function this.callback_onAdd
as an argument of A.add()
.
Contract A
can obtain the address of the caller and the selector of the callback function through the callback
parameter. It puts the result of a + b
in the first argument and initiates a call to the callback
function to return data to B
.
In the callback function callback_onAdd()
of contract B
, it gets the result of A.add()
from the parameter result
and assigns it to the storage variable data
.
It works fine and the code doesn't look too complicated, no pain at all, right?
Once the call stack gets deeper, you won't think so anymore.
# Callback Hell
Let's expand this example a bit to see what happens.
In a more complex example:
- We add a
sum()
method to contractB
, that calculates the sum of 3 integers by calling theadd()
method of contractA
twice, and returns the final result to the caller by initiating a callback. - Add another contract
C
to call contractB
to calculate the sum of the integers1
,2
, and3
.
In Solidity, implementing such an extension is straightforward.
We can initiate two calls to A.add()
in sequence in the B.sum()
function, and pass the partial result returned from the first call as the argument of the second call.
Here is the implementation in Solidity:
We can see that the contract A
doesn't change anything compared to the previous example.
So can contract A
also remain unchanged with the implementation using callbacks in Solidity++?
Unfortunately, the answer is NO. We have to extend the called contracts to keep them composable in Solidity++ 0.8.0, the code is as follows:
Let's see what additional work is required to keep the contracts composable in Solidity++ with only callbacks:
We need to add two callback functions to contract
B
. In the first callback functionB.callback_onAdd1()
, we receive the partial result of the first call toA.add(a, b)
, and use this as an argument to initiate the second call toA.add()
. In the second callback functionB.callback_onAdd2()
, we receive the final result of the second call toA.add(a, b)
, and use this to initiate the callback to contractC
.In order to initiate a callback request to the original caller
C
to return the final result,B
must record the address of the origin.B
can obtain the address ofC
in the functionB.sum()
, but cannot pass it to the callback functionB.callback_onAdd2()
, because the VM context cannot be shared between different functions due to the asynchronous nature of Vite. Therefore, we have to put the address and callback method id in anorigin
parameter of theA.add()
function.Similarly, in order to use the third parameter
c
of theB.sum()
function as the second argument of the second call toA.add()
, we need to extend the parameter list ofA.add()
andB.callback_onAdd1()
by adding acontext
parameter.
As we can see, even for such a simple scenario, the contract code has become cluttered.
It comes from the famous problem of Callback Hell: Using low-level callbacks makes code difficult to write and understand. It also increases the difficulty of identifying the control flow for a contract.
# Composability Using Await Operator
In order to make contracts written in Solidity++ as simple and straightforward as those written in Solidity, we need to introduce an await
operator as in some modern programming languages such as javascript and C#.
Await operator suspends evaluation of the enclosing async function until the asynchronous operation represented by its operand completes. When the asynchronous operation completes, it returns the result of the operation, if any.
It helps you write synchronous-looking Solidity++ code that works asynchronously.
When the example is implemented in Solidity++ with await
operator, the code will be as straightforward and concise as in Solidity:
# Implementation
# Objectives
To make this happen, the Solidity++ compiler and Vite VM have to do most mechanical works for developers under the hood.
Let's dissect the code to see what specific work needs to be done by the compiler and VM:
In summary, there are the following tasks for the compiler and VM:
- To generate callback entries for each synchronous call automatically at compile time.
- To initiate a callback request when the result is returned.
- To maintain execution context during function invocations and callbacks.
# Solidity Compiler and EVM
Before deciding how to implement it, let's take a look at the output from Solidity compiler for this example.
Pseudo assembly code for contract A
:
Pseudo assembly code for contract B
:
To make the control flow clear, let's put these in an anatomy diagram:
As shown by the red arrows in the diagram, the EVM executes external function calls through the call
and return
instructions.
The Solidity compiler will create a piece of code called function selector for each contract that includes the entries to interface functions of the contract at compile time.
When executing a message call to a contract, EVM extracts the first four bytes from calldata and compare against the method id of each function. If there’s a match, then jump to the definition of a corresponding function and keep executing. If there’s no match, then jump to the fallback function either defined by user or automatically created by the compiler.
When the call
instruction gets executed, the EVM will retrieve the callee's address and load the callee’s code from the address and execute the code in an independent context.
When the return
instruction gets executed, the EVM will place the result of the execution into the memory and exits from the callee's code, and then keep executing the remaining code of the caller.
# Solidity++ Compiler
Let's start extending the functionality on the Solidity compiler, introducing the new features mentioned above:
- To generate a callback entry, the compiler needs to traverse all
await
expressions in the contract at compile time, add a tag to each position immediately after theawait
, and add it to the function selector as a callback entry. - To initiate a callback request when the result is returned, we need to reimplement the
return
instruction in the Vite VM to initiate a send transaction with type ofSendCallback
. - To maintain execution context, two new instructions
synccall
andcallbackdest
need to be introduced into the Vite VM.
The compiler places a synccall
instruction at each synchronous call. When the VM executes the synccall
instruction, it initiates a send transaction with type of SendSyncCall
and persists the execution context into the local database.
Similarly, the compiler places a callbackdest
instruction at each callback. When the VM executes the callbackdest
instruction, it restores the execution context from the local database and then resumes execution.
This design allows the compiler to generate code that can run on an asynchronous VM for contracts written in a programming language similar to Solidity.
The example will be compiled to the following code:
Pseudo assembly code for contract A
:
Pseudo assembly code for contract B
:
function_selector:
method_id := calldata(0, 4)
if method_id == sig_hash(test)
jump(tag_function_test)
else if method_id == 0x00000001
jump(tag_callback_01)
else
fallback()
stop
tag_function_test:
contract_address := contractA
method_id := sig_hash(add)
params := abi_encode(1, 2)
callback_id := 0x00000001
synccall(contract_address, method_id, params, callback_id)
stop
tag_callback_01:
callbackdest
ret := abi_decode(calldata)
return(ret)
Again, let's put these in an anatomy diagram:
The blue part of the assembly code is generated by the Solidity++ compiler specifically for the Vite VM.
The orange and green circles represent the send (request) transaction and receive (response) transaction initiated by the Vite VM, respectively.
As we can see, the compiler translates each await
expression into a set of instructions in the same pattern: inserting a synccall
instruction, a stop
instruction, a jump dest tag (a jumpdest
instruction), and a callbackdest
instruction.
Essentially, the Vite VM treats the synccall
instruction as an interrupt, which is a request for the VM to interrupt currently executing code, save its context, and initiate an asynchronous request transaction.
Correspondingly, before receiving a callback and resuming execution, the execution context needs to be restored by executing the callbackdest
instruction.
This pattern is known as context switch. In a multi-task system, it refers to the process of storing the system state for a task, so that task can be paused and resumed later.
# Vite VM
Vite VM retains the semantics of most EVM instructions, but is not a clone of EVM. Therefore, new instructions such as synccall
and callbackdest
need to be implemented from scratch, and some EVM instructions such as return
, returndatasize
, returndatacopy
need to be reimplemented to adapt to the Vite protocol.
# Transaction Types
Three new transaction types need to be introduced:
- SendSyncCall: A send transaction initiated by a synchronous call,
- SendCallback: A send transaction initiated by a callback that successfully executes and returns a result.
- SendFailureCallback: A send transaction initiated by a callback that fails to execute and returns an error.
# Execution Context
Each transaction with one of the three new types contains an execution context data stored in the database.
The execution context is defined as follows:
For a SendSyncCall
transaction, its execution context includes:
referrer
holds the send transaction hash of the upstream (origin) sync call.callback
holds the 4-bytes method id of the callback function entry.stack
holds a snapshot of the contract stack at the timesynccall
is executed.memory
holds a snapshot of the contract memory at the timesynccall
is executed.
For a SendCallback
or a SendFailureCallback
transaction, its execution context only includes a referrer
field:
referrer
holds the send transaction hash of the latest sync call.
If referrer
confuses you, see the example below:
# SyncCall Instruction
The synccall
instruction is implemented in pseudocode as follows:
# Return Instruction
The return
instruction is implemented in pseudocode as follows:
# CallbackDest Instruction
The callbackdest
instruction is implemented in pseudocode as follows: