Skip to main content
Version: Next

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 termMavryk termNotes
World stateContext
AccountContract or AccountIn Mavryk, both smart contracts and accounts controlled by private keys are referred to as "contracts"
Externally-owned accountImplicit account
ContractSmart contract or Originated contract
Contract deploymentContract origination
TransactionOperationIn Mavryk, there is a distinction between transactions that transfer value, contract originations, and other kinds of operations
TransactionOne possible type of operation (value transfer or contract invocation)
MinerBakerMavryk uses proof-of-stake, so bakers do not solve proof-of-work puzzles. They do produce new blocks and receive rewards, though
Contract stateContract storage
Contract methodEntrypoint
View methodCurrently, 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:

type nat_alias = nat;
type token_amount = | ["TokenAmount", nat];

As in Solidity, there are record types:

type creature = { heads_count: nat, legs_count: nat, tails_count: nat };

There are also variant types (or "sum types") – a more powerful counterpart of Solidity enums that can hold data:

type int_option = ["Number", int] | ["Null"];
let x = Number(5);
let y = Null ();

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:

let x = Some(5);
let y: option<int> = None();

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:

let x = Some (5);
let x_or_zero =
match(x) {
when(Some(value)): value;
when(None()): 0
};

We can go further and combine variant types with records:

type committee = {members: list<address>, quorum: nat };
type leader = {name: string, address: address };
type authority = ["Dictatorship", leader] | ["Democracy", committee];

Contracts and entrypoints

In Solidity, you usually define a contract with methods and fields:

contract Counter {
int public counter; // storage field
function increment() public { ... } // method
function decrement() public { ... } // method
}

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:

let main = (parameter: bytes, storage: int): [list<operation>, int] => {
if (parameter == 0xbc1ecb8e) {
return [list([]), storage + 1]
} else {
if (parameter == 0x36e44653) {
return [list([]), storage - 1]
} else {
return (failwith("Unknown entrypoint"))
}
}
};

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:

type storage = int;
type result = [list<operation>, int]
@entry
const increment = (_u : unit, s : storage) : result => [list([]), s + 1]
@entry
const decrement = (_u : unit, s : storage) : result => [list([]), s - 1]

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:

type storage = int;
type result = [list<operation>, int]
@entry
const add = (i : int, s : storage) : result => [list([]), s + i]
@entry
const subtract = (i : int, s : storage) : result => [list([]), s - i]

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:

const Counter = artifacts.require('Counter')
let counterInstance = await Counter.deployed()
await counterInstance.add(100)

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:

type storage = int
type result = [list<operation>, storage]
let doMultiplyBy2 = (store : storage) : int => store * 2;
let doMultiplyBy4 = (store : storage) : int => doMultiplyBy2(doMultiplyBy2(store));
@entry const multiplyBy4 = (_u : unit, s : storage) : result => [list([]), doMultiplyBy4(s)]
@entry const multiplyBy16 = (_u : unit, s : storage) : result => [list([]), doMultiplyBy4(doMultiplyBy4(s))]

Here:

  1. multiplyBy2 is private (in Solidity terms): we cannot call it directly from outside of the contract.
  2. multiplyBy4 is public: we can call it both from inside the contract and using the %multiplyBy4 entrypoint.
  3. %multiplyBy16 is external: there is no function multiplyBy16 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:

type storage = int;
@entry
const compute = (func: ((v : int) => int), s: storage) : [list<operation>, int] =>
[list([]), func(s)]

We can then call this contract with the parameter of the form Compute ((x : int) => x * x + 2 * x + 1). Try this out with:

ligo run interpret 'main([Compute ((x : int) => x * x + 2 * x + 1), 3])' --init-file examples/contracts/jsligo/Lambda.jsligo

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:

type storage = { fn : option<((x : int) => int)>, value : int };
type result = [list<operation>, storage];
const call = (fn: option<((x : int) => int)>, value: int) : int => {
return match(fn) {
when(Some(f)): f(value);
when(None()): failwith("Lambda is not set")
}
};
@entry
const setFunction = (fn : ((v : int) => int), s : storage) : result =>
[list([]), {...s, fn: Some(fn)}];
@entry
const callFunction = (_u : unit, s : storage) : result =>
[list([]), {...s, value: call(s.fn, s.value)}];

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:

contract Treasury {
uint256 public rewardsLeft;
IBeneficiary beneficiary;
function disburseRewards() public {
require(rewardsLeft != 0);
beneficiary.handleRewards().call.value(rewardsLeft);
// the execution is paused until `handleRewards` returns
rewardsLeft = 0;
}
}

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:

contract Beneficiary {
function handleRewards() public payable {
Treasury treasury = Treasury(msg.sender);
if (msg.sender.balance > treasury.rewardsLeft) {
treasury.disburseRewards();
}
}
}

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:

type storage = {rewardsLeft: mav, beneficiaryAddress: address };
let treasury = (p : unit, s : storage) => {
// We do our computations first
let newStorage = {...s, rewardsLeft: 0mumav};
// Then we find our beneficiary's `handleRewards` entrypoint:
let beneficiaryOpt = Mavryk.get_entrypoint_opt("%handleTransfer", s.beneficiaryAddress);
let beneficiary =
match(beneficiaryOpt) {
when(Some(contract)): contract;
when(None()): failwith("Beneficiary does not exist")
};
// Then we prepare the internal operation we want to perform
let operation = Mavryk.transaction(unit, s.rewardsLeft, beneficiary);
// ...and return both the operations and the updated storage
return [list([operation]), newStorage];
};

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:

  1. 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.
  2. 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.
  3. 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 patternLIGO pattern
public fieldA field in the storage record, e.g. type storage = { x : int, y : nat }
private fieldN/A: all fields are public
private methodA regular function, e.g., let func = (a : int) => ...
public / external methodA 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 methodThere is no concept of inheritance in Mavryk
ConstructorSet the initial storage upon origination
Method that returns a valueInspect 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 patternPut 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