Side effects and Errors

Smart contracts usually interact with each other and in some cases (provided some conditions) they are supposed to fail. Indigo supports side-effects and errors to handle these needs, respectively.

Failing instead

In the contract from the previous chapter we performed a check, based on the parameter of the contract, and modified the storage depending on the result of said check.

When the check didn't pass we "reset" the storage to 0, however, this may not always be what we want. Instead we may only want to increment the storage when the check passes and stop the execution entirely when it doesn't.

This chapter has an Errors.hs file that contains errorsContract:

module Indigo.Tutorial.SideEffects.Errors
  ( errorsContract
  ) where

import Indigo

data IncrementIf
  = IsZero Integer
  | HasDigitOne Natural
  deriving stock (Generic, Show)
  deriving anyclass (IsoValue)

instance ParameterHasEntrypoints IncrementIf where
  type ParameterEntrypointsDerivation IncrementIf = EpdPlain

errorsContract :: IndigoContract IncrementIf Natural
errorsContract param = defContract do
  case_ param $
    ( #cIsZero #= checkZero
    , #cHasDigitOne #= checkHasDigitOne
    )
  incrementStorage

checkZero :: Var Integer -> IndigoProcedure
checkZero val = defFunction do
  assert [mt|unacceptable zero value|] (val == 0 int)

checkHasDigitOne :: Var Natural -> IndigoProcedure
checkHasDigitOne val = defFunction do
  base <- new$ 10 nat
  while (val > base) do
    val =: val / base
    remainder <- new$ val % base
    checkSingleDigitOne remainder
  checkSingleDigitOne val

checkSingleDigitOne :: Var Natural -> IndigoProcedure
checkSingleDigitOne digit = do
  assert [mt|unacceptable non-one digit|] (digit == 1 nat)

incrementStorage :: HasStorage Natural => IndigoProcedure
incrementStorage = defFunction do
  storage += 1 nat

storage :: HasStorage Natural => Var Natural
storage = storageVar

It differs from the previous contract in two ways:

  • as said we want to stop execution when a check does not succeed
  • we changed the logic of the checkHasDigitOne from "needs at least one digit equal to 1" to "needs all digits to be equal to 1". This is just for demonstration purposes, it could have had the same logic.

This contract, or rather its functions, use the most common error-throwing mechanism, which is to check for a Boolean condition and fail if it is False. In such cases the assert statement comes in handy: it expects a message for the possible error and a Boolean expression for the condition.

If we take a look at the errorsContract we can see that, compared to the previous one:

  result <- case_ param $

we do not want the Boolean result from the case_:

  case_ param $

instead we let the check end in failure if necessary:

checkZero val = defFunction do
  assert [mt|unacceptable zero value|] (val == 0 int)
checkSingleDigitOne digit = do
  assert [mt|unacceptable non-one digit|] (digit == 1 nat)

Note that after the case_ we can now just perform incrementStorage, this is because a failure short-circuits the execution of the contract, meaning that if we get to this point we can be certain that the previous check did not fail:

errorsContract :: IndigoContract IncrementIf Natural
errorsContract param = defContract do
  case_ param $
    ( #cIsZero #= checkZero
    , #cHasDigitOne #= checkHasDigitOne
    )
  incrementStorage

You can see how simple the usage of assert is, by looking at the new implementation of checkZero above.

If you are surprised by how the message is constructed, take a look at the types reference, this is just an MText.

Note also that the signature for checkZero has changed and now it is an IndigoProcedure, because it now returns nothing instead of a Boolean:

checkZero :: Var Integer -> IndigoProcedure

Keeping in mind the difference in logic for checkHasDigitOne described above, you should be easily able to understand how it is implemented:

checkHasDigitOne :: Var Natural -> IndigoProcedure
checkHasDigitOne val = defFunction do
  base <- new$ 10 nat
  while (val > base) do
    val =: val / base
    remainder <- new$ val % base
    checkSingleDigitOne remainder
  checkSingleDigitOne val

Apart from the assert that we show here, Indigo also supports these constructs for failing:

  • failWith that has one argument: an expression for any type, this is the same behavior as Michelson's FAILWITH
  • failCustom that expects a Label and an error message to compose a custom failure value
  • failCustom_ that has only one Label argument, and is a specialization of the previous one. See an example on how to use this in: Custom error messages
  • failUnexpected_ that has one argument: an expression for an error message, just like assert
  • assertCustom that works just like assert, but with the same inputs as failCustom
  • assertCustom_ that works just like assert, but with the same input as failCustom_

Side Effects

Michelson provides three instructions to perform external operations: - CREATE_CONTRACT - TRANSFER_TOKENS - SET_DELEGATE

and expects the operations you produce to be put into a list and returned with the new storage of the contract at the end of its execution.

Indigo handles the operations you create automatically and makes sure they are returned as Michelson expects them to be. So the statements it provides for the instructions above are:

  • createContract returns an Address and takes: the IndigoContract to create, a Maybe KeyHash for the optional delegate, a Mutez for the initial amount to take from the originating contract and the initial storage
  • transferTokens returns nothing and takes: a parameter for the destination contract, a Mutez to transfer to it from the calling contract and a ContractRef
  • setDelegate returns nothing and takes a Maybe KeyHash

In the second file for this chapter, SideEffects.hs, we can see an example of a contract with side effects, sideEffectsContract:

module Indigo.Tutorial.SideEffects.SideEffects
  ( sideEffectsContract
  , textKeeper
  ) where

import Indigo

sideEffectsContract :: IndigoContract (Maybe MText) Address
sideEffectsContract param = defContract do
  deleg <- new$ none
  mtz <- new$ zeroMutez
  notInit <- new$ [mt|not initialized|]
  addr <- createContract textKeeper deleg mtz notInit
  ifSome param (initializeContract addr) (return ())
  storage =: addr

textKeeper :: IndigoContract MText MText
textKeeper param = defContract do
  storageVar =: param

initializeContract :: (HasSideEffects, IsNotInView) => Var Address -> Var MText -> IndigoProcedure
initializeContract addr msg = defFunction do
  mtz <- new$ zeroMutez
  ifSome (contract addr)
    (\cRef -> transferTokens msg mtz cRef)
    failForRef

failForRef :: IndigoProcedure
failForRef = failUnexpected_ @() [mt|failed to create contract reference|]

storage :: HasStorage Address => Var Address
storage = storageVar

As you can see this contract uses createContract with textKeeper that we took from a previous example and creates it with the not initialized string. It then decides, using ifSome, to change its storage or not with transferTokens.

Note that, much like it happens for HasStorage, functions that want to use side effects require the constraint HasSideEffects to be specified. If you need a function that does both, remember that you can do so by using the syntax in this formula:

<function_name> :: (HasStorage <storage_type>, HasSideEffects) => IndigoFunction <return_type>

initializeContract also specifies IsNotInView constraint; jakarta protocol forbids using some instructions in on-chain views, transferTokens is one of those; defContract automatically provides this constraint, but in a standalone function we have to specify it explicitly.

If you followed up to this point this contract should not be confusing, remember that you can check out the reference pages for types and for expressions if you don't recognize something.

On the next chapter, we will explain how to add simple documentations to our contracts: Contract documentation.

Technical details: SideEffects and Errors

There is not much to say about these two.

Regarding side effects, contract compilation makes sure to create a list of Operations and assign a Variable to it that is accessible using Given. At the end of the contract this list is paired with the storage. You can check this out in the Indigo.Compilation module.

Statements with side effects can then get this Var and update the list it refers to, adding a new Operation to it.

Failure is instead handled by using the relative Lorentz instruction and filling the rest of the resulting GenCode with errors.

Safety of using error

We can safely do this because those fields are not going to ever be evaluated, due to being lazy, and so they will never actually throw an error.

You can read more about it by looking at the Indigo.Backend.Error module.

As a reminder Indigo has Haddock documentation, if you are interested more in learning about its internals and exported content alike.