Skip to main content

Logic and Actor State

Logic and Actor state are the most important concepts in MOI blockchain. While logic state works as a regular smart-contract storage, known in other blockchains, actor state is the key feature that enables massive parallelism when executing logic calls. Actor state enables logics to store their data on actors, so when actors participate in an interaction with logic, they bring their own data, while the same logic can be used in other interactions with different actors at the same time.

As every logic can write the data to actors, each actor holds the data of multiple logics in its storage. Even logics themselves can act as actors, they can contain their own storage (logic state) and also the data of other logics. Logic can write (mutate) it's own data (either on it's own logic state or on any participating actor, e.g. on Sender) and it can read (observe) any data of any other logic. But it can't write (mutate) to other logic's data, not even in its own context.

In short, Coco supports two kinds of states:

  • logic state — data stored under the logic itself (requires locking the logic to mutate).
  • actor state — data stored under an actor’s context (the logic can mutate its state on multiple actors in parallel).

Logic state (left) vs Actor state (right). Each actor carries slots for multiple logics.

In this diagram we see how storage ("context") of each actor (logic is also an actor for other logics) contains the data of different logics. Logic stores its own data (state logic) at the beginning of the storage while it can access its actor storage on actor context. Logic can observe and mutate its own data on actors, but only when actors participate in an interaction. E.g., Sender always participates so logic can read and write its data, stored in the Sender's context.

In the Logic box above one can notice some data outside of boxes (0xabdd...), this represents a single ("atomic") value of a complex object, e.g. each key of the map "balances" in slot 1 is stored in a different place, what's explained in the next section.

Storing Complex Objects

While primitive types (numbers, strings etc.) are stored in individual slots, complex types - maps, arrays and classes - have their contents scattered through the storage, what is called in MOI atomic storage. This enables very high efficiency for data management as we usually only access a few elements of such large structures. E.g. we can have a map with thousands of objects, but we only deal with the one that matches the current Sender. This implementation of storage enables MOI to only transfer a small share of data between the network storage and runtime (PISA) what drastically reduces gas costs.

Implementation of atomic storage is largely invisible to Coco with two exceptions. Transfer of complete complex objects (e.g. a complete map) to and from the state is not allowed by default, but if the developer explicitly instructs to compiler to allow such potentially costly operations, they can use micro keywords gather and disperse to observe or mutate complex objects in the storage. The use of both is in general discouraged, except for limited cases of e.g. initializing data structures that we know are empty or small, like in the example.

In the image above, an example of stored map balances is show where slot 1 only holds the length of the map and values are stored individually in places identified by key hashes. Actual key hash is a combination of logic identifier, slot and key, so there are no collisions between elements of different logics even if they are exactly the same.

Accessing State

Data in state is always accessed using a three-component identifier:

  • Logic state: Module.Logic.field
  • Actor state for current sender: Module.Sender.field
  • Actor state for a specific actor: Module.Actor(actor_id).field

Module is always the name of our coco module, to access the state of other logics look into more information about interfaces.

Logic state values are accessed using the module name, keyword Logic and the field name, e.g.

ERC20.Logic.name

Actor states are accessed using the module name, actor identifier, and field name. Sender is a commonly used identifier of the actor that has invoked the endpoint, so the sender's state can be accessed as:

Flipper.Sender.value

Instead of Sender, logic can also access its state on any Actor that participates in the invocation of the endpoint (interaction on MOI blockchain), including any participating logic. An actor's identifier is usually passed as an argument to the endpoint, but it can be just a fixed identifier if it's know before the logic is written and deployed to the blockchain.

well_known_actor.coco
// an identifier we already know
const STAR_ACTOR Identifier = 0xcadffe5d6654f1a6d2cc766d7ddaf8485307b2ebb351551b1a57bf1fcec54be5
observe star_value <- Flipper.Actor(STAR_ACTOR).value
TokenLedger.coco
coco TokenLedger

// let's have two fixed identifiers to use later
const I1 Identifier = 0x1111111111111111111111111111111111111111111111111111111111111111
const I2 Identifier = 0x2222222222222222222222222222222222222222222222222222222222222222

state logic:
supply U64
balances Map[Identifier]U64

endpoint deploy SeedSupply(supply U64, seed Identifier):
mutate supply -> TokenLedger.Logic.supply
mutate balances <- TokenLedger.Logic.balances:
balances[seed] = supply

endpoint dynamic LoadAllBalances():
memory local_balances Map[Identifier]U64
local_balances[I1] = 100
local_balances[I2] = 200
mutate balances <- TokenLedger.Logic.balances:
// this may be expensive if the map is large
// so we need to explicitly "disperse" the map
disperse balances <- local_balances
actor_state.coco
coco ActorState

state actor:
data String

endpoint GetData(actor_id Identifier) -> (data String):
observe data <- ActorState.Actor(actor_id).data

endpoint GetSpecialData() -> (data String):
observe data <- ActorState.Actor(Identifier(0x1111111111111111111111111111111111111111111111111111111111111111)).data

Observing State

Observe statement is used to capture values from the state and sets it to a value.

observe map <- ModMutate.Logic.num:

If an observe statement ends with a : sign then it can have a body in which the value that has been observed can be used as a local variable.

Multiple targets and values can be listed in a single statement, e.g.

observe name, symbol, value <- Mod.Logic.name, Mod.Logic.symbol, Mod.Sender.value

Mutating State

Mutate statement is used to set a module value to the state.

mutate n -> ModMutate.State.check

In this case the the value in ‘n’ is set to the check field of the persistent state.

mutate num <- ModMutate.State.num:

If a mutate statement ends with a ‘:’ then the statement can have a context block. In the case of a mutate statement with a context the final value of num after execution of the block is set back into the persistent state at the end of the mutation context. num can’t be a variable name that’s already declared and it’s local to the mutate block.

As with observe, mutate accepts multiple targets & values in a statement. Nesting is also allowed, so mutating values inside mutate block is possible.

coco Mod

state logic:
name String

state actor:
counter U64

endpoint deploy Init():
mutate "MyLogic" -> Mod.Logic.name

endpoint dynamic Tick():
mutate c <- Mod.Sender.counter:
c += 1

endpoint Counter() -> (name String, counter U64):
observe counter <- Mod.Sender.counter
observe name <- Mod.Logic.name

Observing and Mutating Complex Values using disperse and gather

Coco takes care to avoid expensive operations like transferring a large amount of data from the storage (state). E.g., if we want to only alter a single value in the map, there’s no need to load a complete map and store it back.

If we actually want to transfer a complete array, map or a class from the storage into memory, we need to explicitly prepend the assignment or append statement with gather keyword, and if we’re storing the data from memory into storage, we need to prepend disperse.

complex_values.coco
coco Complex

state logic:
Operators Map[U64]Operator

class Operator:
field Identifier String
field Guardians []String

endpoint deploy Init(moid String):
mutate operators <- Complex.Logic.Operators:
disperse operators[0] <- Operator{
Identifier: moid,
Guardians: make([]String, 0),
}

endpoint Op0() -> (op Operator):
memory o Operator
observe operators <- Complex.Logic.Operators:
gather o <- operators[0]
op = o

Note on parallelism

The design of the example on the right shows an anti-pattern that prevents parallel execution of the logic. As every invocation of GimmeGimme mutates logic's state (decreases supply), the logic must get a write lock for each invocation and it can't run in parallel. That's why mutating state logic needs to be limited only to initialization and exceptional cases, any endpoint that will be massively used should only use state actor and use the data the participating actors bring into the transaction, so the logic can run in parallel.

The example also shows why Ccoo and MOI use native programmable assets that enable parallel execution of transfers and other operations on assets.

locked_logic.coco
coco Client

state logic:
supply U64

state actor:
balance U64

endpoint deploy SeedSupply(): // initialize state when deploying logic
mutate 100 -> Client.Logic.supply

endpoint enlist Alms():
mutate 2 -> Client.Sender.balance // some small initial balance

// adds 1 to the Sender's balance, if there's still some supply
endpoint dynamic GimmeGimme():
mutate sup <- Client.Logic.supply:
sup -= 1 // if supply is gone, this throws an error
mutate cs <- Client.Sender.balance:
cs += 1