Migrating from Ethereum
This article is aimed at those who have some experience with developing smart contracts for Ethereum in Solidity. We will cover the key differences between Solidity and LIGO, compare the execution model of Ethereum and Mavryk blockchains, and list the features you should be aware of while developing smart contracts for Mavryk.
Languages and libraries
Mavryk is an upgradeable blockchain that focuses on decentralisation. It offers a wide variety of languages, frameworks, and tools you can use to develop your contracts. In this article, we mainly focus on the LIGO language, and the provided examples use the Truffle framework for testing. However, many of the points here cover the inherent differences in the blockchain architectures, so they should be valid for other languages and frameworks in the Mavryk ecosystem.
The current Mavryk protocol uses the Michelson language under the hood. Michelson is much like EVM in Ethereum, inasmuch as its programs are low-level code executed by an embedded virtual machine. Nevertheless, contrary to EVM byte-code, Michelson is a strongly-typed stack-based language designed to be human-readable.
Having a human-readable representation of compiled contracts makes it harder for compiler bugs to pass unnoticed: everyone can review the Michelson code of the contract and even formally prove its correctness.
LIGO is a family of high-level languages. There are several flavours or syntaxes of LIGO – CameLIGO, and JsLIGO. The developers may choose whatever syntax looks more familiar to them.
Terminology
For those who come from the Ethereum world, the terminology used in Mavryk may be misleading. Mavryk developers chose to not reuse the same terms for similar concepts for a reason: a false sense of similarity would be a bad friend for those migrating to a different blockchain architecture.
We will, however, try to associate the terms known to you with the terms used in Mavryk. Note that this is just an association, and not the exact equivalence.
Ethereum term | Mavryk term | Notes |
---|---|---|
World state | Context | |
Account | Contract or Account | In Mavryk, both smart contracts and accounts controlled by private keys are referred to as "contracts" |
Externally-owned account | Implicit account | |
Contract | Smart contract or Originated contract | |
Contract deployment | Contract origination | |
Transaction | Operation | In Mavryk, there is a distinction between transactions that transfer value, contract originations, and other kinds of operations |
– | Transaction | One possible type of operation (value transfer or contract invocation) |
Miner | Baker | Mavryk uses proof-of-stake, so bakers do not solve proof-of-work puzzles. They do produce new blocks and receive rewards, though |
Contract state | Contract storage | |
Contract method | Entrypoint | |
View method | – | Currently, Mavryk does not provide view functions. You can inspect the storage of the contract, though |
Types and why they matter
If you come from the Solidity world, you may be accustomed to simple types like string
or uint256
, structures, and enums. In LIGO, the types are more advanced. They tend to reflect how the values of these types should be used rather than how they are stored. That is why, for example, LIGO has separate types for signature
and public key
instead of just using a byte string (bytes
) everywhere. Numeric types follow this philosophy: the numbers have arbitrary precision and are, in practice, bounded only by the transaction gas consumption.
Since types are not some purely "technical" concept and have inherent meaning attached to them, it is often a good idea to start thinking about your contract in terms of functions that transform values of one type to values of, possibly, some other type.
You can define new types and type aliases in your code using the type
keyword:
As in Solidity, there are record types:
There are also variant types (or "sum types") – a more powerful counterpart of Solidity enums that can hold data:
Valid values of this type are regular numbers wrapped in Number
(e.g., Number(5)
, Number(10)
, etc.) or Null
. Notice how Null()
does not hold any value.
There is a special built-in parameterised option
type with Some
and None
constructors, so we can rewrite the snippet above as:
This is how we express nullability in LIGO: instead of using a special ad-hoc value like "zero address", we just say it is an option<address>
. We can then use match
to see if there is something inside:
We can go further and combine variant types with records:
Contracts and entrypoints
In Solidity, you usually define a contract with methods and fields:
When the contract is compiled, the Solidity compiler automatically adds dispatching logic into the resulting EVM byte-code. The contract inspects the data passed to it and chooses a method based on the first four bytes of the method's signature hash: 0xbc1ecb8e
means increment()
and 0x36e44653
means decrement()
.
In Mavryk, a contract must define a default entrypoint that does the dispatching. It is much like a main
function in C-like languages. It accepts a typed parameter (some data that comes with a transaction) and the current value of the contract storage (the internal state of the contract). The default entrypoint returns a list of internal operations and the new value of the storage.
For example, we can simulate Ethereum dispatching behaviour:
However, we can do better. As we discussed, LIGO has a much richer type system than Solidity does. We can encode the entrypoint directly in the parameter type. that the entry points are either Increment
or Decrement
, and implement their behaviours as separate functions:
We do not need any internal operations, since we neither call other contracts nor transfer money. Here is how we can add arguments to our entrypoints:
Mavryk has special support for parameters encoded with variant types. If the parameter is a variant type, Mavryk will treat each constructor as a separate entrypoint (with the first letter lowercased). It is important when we want to call a contract but do not know the full type of its parameter. For example, we can call our counter contract with the following CLI command:
mavryk-client call contract counter from alice --entrypoint '%subtract' --arg 100
Truffle (and Taquito library, which Truffle for Mavryk uses under the hood), also treats entrypoints specially. We can call our add
entrypoint as follows:
Visibility modifiers
Solidity has visibility modifiers like private
and public
for storage entries and contract methods. LIGO has none of these, and you may be wondering why. To answer this, we will first consider storage modifiers and then discuss methods (entrypoints).
It is a popular misconception in the Ethereum world that by marking a storage field private
you can make this field visible only from inside the contract. Both in Mavryk and Ethereum, the contract storage is public. This is due to how blockchains work: nodes need to read contracts' storage to execute and validate the transactions. Mavryk allows anyone to inspect storage of any contract with one CLI command. In Ethereum, it is harder but still feasible.
Making a storage field public
in Solidity instructs the compiler to generate a view
method that returns the value of this field. In Mavryk, it is possible to inspect the storage directly, and it is not possible to return values from contract calls. Thus, public and private storage fields are equivalent in Mavryk.
For contract methods, the dispatching logic defines which functions within the contract are accessible from the outside world. Consider the following snippet:
Here:
multiplyBy2
is private (in Solidity terms): we cannot call it directly from outside of the contract.multiplyBy4
is public: we can call it both from inside the contract and using the%multiplyBy4
entrypoint.%multiplyBy16
is external: there is no functionmultiplyBy16
in the contract so we cannot call it from inside the source code, but there is an entrypoint%multiplyBy16
encoded in the parameter, so we can use mavryk-client or Taquito to call it externally.
There is no analogue of internal
methods in LIGO because LIGO contracts do not support inheritance.
Lambdas
In Mavryk, you can accept code as a parameter. Such functions that you can pass around are called lambdas in functional languages. Let us say that we want to support arbitrary mathematical operations with the counter value. We can just accept the intended formula as the parameter:
We can then call this contract with the parameter of the form Compute ((x : int) => x * x + 2 * x + 1)
. Try this out with:
The interpreted output is ( LIST_EMPTY() , 16 )
, which is an empty list of operations and the new storage value – the result of the computation.
But this is not all lambdas are capable of. You can, for example, save them in storage:
Now we can upgrade a part of the implementation by calling our contract with SetFunction ((x : int) => ...)
.
Execution model
In Ethereum, you often find yourself "calling" other contracts and splitting your business logic into multiple independent parts. When you call some other contract, the transaction execution is paused until the callee returns the result. We will refer to such invocations as direct calls:
Those of you experienced with Solidity may notice that this contract is not reentrancy-safe: the beneficiary contract may utilise the fact that by the time of the call, rewardsLeft
storage variable has not been updated. The attacker can call back into the caller contract, invoking disburseRewards
until it drains the treasury contract:
In Mavryk, the execution model is quite different. Contracts communicate via message passing. Messages are called internal operations. If you want to pass a message to another contract, you need to finish the computation first, and then put an operation into the operations queue. Here is how it looks like:
Note that all the state changes occur before the internal operation gets executed. This way, Mavryk protects us from unintended reentrancy attacks. However, with complex interactions chain, reentrancy attacks may still be possible.
It is a common idiom in Ethereum to make read-only calls to other contracts. Mavryk does not offer a straightforward way to do it but you might think of using something like a callback mechanism:
However, here you leave your contract in an intermediate state before making an external call. You would need additional precautions to make such callback-style calls secure. In most cases, you should avoid this pattern.
By making contract interactions harder, Mavryk incentives you to simplify your architecture. Think about whether you can use lambdas or merge your contracts to avoid complex inter-contract dependencies. If it is possible to not split your logic into multiple contracts, then avoid the split.
You can find more details on how Mavryk contracts interact with each other in our inter-contract calls article.
Fees
Fee model in Mavryk is more complicated than the Ethereum one. The most important bits you should know about are:
- In Mavryk, you burn a certain amount of Mav for increasing the size of the stored data. For example, if you add a new entry to a map or replace a string with a longer one, you must burn your Mav tokens.
- When you call a contract, the transaction spends gas for reading, deserialising and type-checking the storage. Also, a certain amount of gas gets spent for serialising and writing the storage back to the context. In practice, it means that the larger your code and storage are, the more expensive it is to call your contract, regardless of the number of computations performed. If you have big or unbounded containers in storage, you should most probably use
big_map
. - Emitting internal operations is very expensive in terms of gas: there is a fixed cost of 10000 gas for
Mavryk.get_{contract, entrypoint}_opt
plus the cost of reading, deserialising, and type-checking the parameter of the callee.
Always test for gas consumption and strive to minimise the size of the data stored on chain and the number of internal operations emitted. You can read more on fees in our Optimisation guide or in the Serokell blog post.
Conclusion
In this article, we discussed some Solidity patterns and their LIGO counterparts. We also covered the most important aspects of the Mavryk execution model and fees. Here is a quick reference table comparing Solidity and LIGO patterns:
Solidity pattern | LIGO pattern |
---|---|
public field | A field in the storage record, e.g. type storage = { x : int, y : nat } |
private field | N/A: all fields are public |
private method | A regular function, e.g., let func = (a : int) => ... |
public / external method | A separate entrypoint in the parameter: type parameter = ["F", int] . main entrypoint should dispatch and forward this call to the corresponding function using a match expression |
internal method | There is no concept of inheritance in Mavryk |
Constructor | Set the initial storage upon origination |
Method that returns a value | Inspect the contract storage directly |
contract.doX(...) | Emit an internal operation |
uint x = contract.getX() | Do not do this. Think if you can merge the contracts or reverse the execution flow |
Proxy upgrade pattern | Put lambdas to storage and provide means to update them |
emit Event(...) | Event logs are not supported at the moment. There is a proposal to support event logs in the future |