Storing data on-chain

Algorand smart contracts can utilise three different types of on-chain storage: Global storage, Local storage, and Box Storage. They also have access to a transient form of storage in Scratch space.

The life-cycle of a smart contract matches the semantics of Python classes when you consider deploying a smart contract as “instantiating” the class. Any calls to that smart contract are made to that instance of the smart contract, and any state assigned to self. variables will persist across different invocations (provided the transaction it was a part of succeeds, of course). You can deploy the same contract class multiple times, each will become a distinct and isolated instance.

During a single smart contract execution there is also the ability to use “temporary” storage either global to the contract execution via Scratch storage, or local to the current method via local variables and subroutine params.

Global storage

Global storage is state that is stored against the contract instance and can be retrieved by key. There are AVM limits to the amount of global storage that can be allocated to a contract.

This is represented in Algorand Python by either:

  1. Assigning any Algorand Python typed value to an instance variable (e.g. self.value = UInt64(3)).

    • Use this approach if you just require a terse API for getting and setting a state value

  2. Using an instance of GlobalState, which gives some extra features to understand and control the value and the metadata of it (which propagates to the ARC-32/ARC-56 app spec file)

    • Use this approach if you need to:

      • Omit a default/initial value

      • Delete the stored value

      • Check if a value exists

      • Specify the exact key bytes

      • Include a description to be included in App Spec files (ARC-32/ARC-56)

For example:

self.global_int_full = GlobalState(UInt64(55), key="gif", description="Global int full")
self.global_int_simplified = UInt64(33)
self.global_int_no_default = GlobalState(UInt64)

self.global_bytes_full = GlobalState(Bytes(b"Hello"))
self.global_bytes_simplified = Bytes(b"Hello")
self.global_bytes_no_default = GlobalState(Bytes)

global_int_full_set = bool(self.global_int_full)
bytes_with_default_specified = self.global_bytes_no_default.get(b"Default if no value set")
error_if_not_set = self.global_int_no_default.value

These values can be assigned anywhere you have access to self i.e. any instance methods/subroutines. The information about global storage is automatically included in the ARC-32/ARC-56 app spec file and thus will automatically appear within any generated typed clients.

GlobalMap is similar to GlobalState, but allows for grouping a set of global state values with a common key and content type. A custom key_prefix can optionally be provided, with the default being to use the member variable name as the prefix. The final state key is the combination of key_prefix + key.

Note

Contracts using GlobalMap must specify adequate state_totals to allocate enough global storage slots for the application on creation.

from algopy import ARC4Contract, Bytes, GlobalMap, StateTotals, UInt64, public


class MyContract(
    ARC4Contract,
    state_totals=StateTotals(global_uints=10),
):
    def __init__(self) -> None:
        self.counters = GlobalMap(UInt64, UInt64)

    @public
    def set_counter(self, key: UInt64, value: UInt64) -> None:
        self.counters[key] = value

    @public
    def get_counter(self, key: UInt64) -> UInt64:
        return self.counters[key]

    @public
    def get_counter_or_default(self, key: UInt64, default: UInt64) -> UInt64:
        return self.counters.get(key, default=default)

    @public
    def check_counter(self, key: UInt64) -> tuple[UInt64, bool]:
        return self.counters.maybe(key)

    @public
    def delete_counter(self, key: UInt64) -> None:
        del self.counters[key]

    @public
    def has_counter(self, key: UInt64) -> bool:
        return key in self.counters

A GlobalMap can also be constructed as a local variable by providing the key_prefix explicitly, which is useful when the prefix needs to be dynamic:

map_ = GlobalMap(UInt64, UInt64, key_prefix=self.counters.key_prefix)
value = map_[key]

The .state(key) method returns a GlobalState proxy for the value at a given key, which can be useful when you need to pass a reference to a single entry:

state = self.counters.state(key)
value = state.value

Local storage

Local storage is state that is stored against the contract instance for a specific account and can be retrieved by key and account address. There are AVM limits to the amount of local storage that can be allocated to a contract.

This is represented in Algorand Python by using an instance of LocalState.

For example:

def __init__(self) -> None:
    self.local = LocalState(Bytes)
    self.local_with_metadata = LocalState(UInt64, key = "lwm", description = "Local with metadata")

@subroutine
def get_guaranteed_data(self, for_account: Account) -> Bytes:
    return self.local[for_account]

@subroutine
def get_data_with_default(self, for_account: Account, default: Bytes) -> Bytes:
    return self.local.get(for_account, default)

@subroutine
def get_data_or_assert(self, for_account: Account) -> Bytes:
    result, exists = self.local.maybe(for_account)
    assert exists, "no data for account"
    return result

@subroutine
def set_data(self, for_account: Account, value: Bytes) -> None:
    self.local[for_account] = value

@subroutine
def delete_data(self, for_account: Account) -> None:
    del self.local[for_account]

These values can be assigned anywhere you have access to self i.e. any instance methods/subroutines. The information about local storage is automatically included in the ARC-32/ARC-56 app spec file and thus will automatically appear within any generated typed clients.

LocalMap is similar to LocalState, but allows for grouping a set of local state values with a common key and content type. A custom key_prefix can optionally be provided, with the default being to use the member variable name as the prefix. The final state key is the combination of key_prefix + key. All access requires both an account reference and a key.

Note

Contracts using LocalMap must specify adequate state_totals to allocate enough local storage slots for the application on creation.

from algopy import Account, ARC4Contract, Bytes, LocalMap, StateTotals, public


class MyContract(
    ARC4Contract,
    state_totals=StateTotals(local_bytes=10),
):
    def __init__(self) -> None:
        self.data = LocalMap(Bytes, Bytes)

    @public(allow_actions=["OptIn"], create="require")
    def create(self) -> None:
        pass

    @public
    def set_data(self, account: Account, key: Bytes, value: Bytes) -> None:
        self.data[account, key] = value

    @public
    def get_data(self, account: Account, key: Bytes) -> Bytes:
        return self.data[account, key]

    @public
    def get_data_or_default(self, account: Account, key: Bytes, default: Bytes) -> Bytes:
        return self.data.get(account, key, default=default)

    @public
    def check_data(self, account: Account, key: Bytes) -> tuple[Bytes, bool]:
        value, exists = self.data.maybe(account, key)
        if not exists:
            value = Bytes()
        return value, exists

    @public
    def delete_data(self, account: Account, key: Bytes) -> None:
        del self.data[account, key]

    @public
    def has_data(self, account: Account, key: Bytes) -> bool:
        return (account, key) in self.data

The .state(key) method returns a LocalState proxy for the value at a given key. The returned proxy still requires an account to access its value:

state = self.data.state(key)
value = state[account]

Box storage

We provide two different types for accessing box storage: Box, and BoxMap. We also expose raw operations via the AVM ops module.

Before using box storage, be sure to familiarise yourself with the requirements and restrictions of the underlying API.

The Box type provides an abstraction over storing a single value in a single box. A box can be declared against self in an __init__ method (in which case the key must be a compile time constant); or as a local variable within any subroutine. Box proxy instances can be passed around like any other value.

Once declared, you can interact with the box via its instance methods.

import typing as t
from algopy import Box, arc4, Contract, op


class MyContract(Contract):
    def __init__(self) -> None:
        self.box_a = Box(arc4.StaticArray[arc4.UInt32, t.Literal[20]], key=b"a")

    def approval_program(self) -> bool:
        box_b = Box(arc4.String, key=b"b")
        box_b.value = arc4.String("Hello")
        # Check if the box exists
        if self.box_a:
            # Reassign the value
            self.box_a.value[2] = arc4.UInt32(40)
        else:
            # Assign a new value
            self.box_a.value = arc4.StaticArray[arc4.UInt32, t.Literal[20]].from_bytes(op.bzero(20 * 4))
        # Read a value
        return self.box_a.value[4] == arc4.UInt32(2)

In addition to being able to set and read the box value, there are operations for extracting and replacing just a portion of the box data which is useful for minimizing the amount of reads and writes required, but also allows you to interact with byte arrays which are longer than the AVM can support (currently 4096).

from algopy import Box, Contract, Global, Txn


class MyContract(Contract):
    def approval_program(self) -> bool:
        my_blob = Box(Bytes, key=b"blob")

        sender_bytes = Txn.sender.bytes
        app_address = Global.current_application_address.bytes
        assert my_blob.create(size=8000)
        my_blob.replace(0, sender_bytes)
        my_blob.splice(0, 0, app_address)
        first_64 = my_blob.extract(0, 32 * 2)
        assert first_64 == app_address + sender_bytes

        value, exists =  my_blob.maybe()
        assert exists
        del my_blob.value
        value, exists = my_blob.maybe()
        assert not exists

        assert my_blob.get(default=sender_bytes) == sender_bytes
        my_blob.create(size=sender_bytes + app_address)
        assert my_blob, "Blob exists"
        assert my_blob.length == 64
        return True

BoxMap is similar to the Box type, but allows for grouping a set of boxes with a common key and content type. A custom key_prefix can optionally be provided, with the default being to use the variable name as the prefix. The key can be a Bytes value, or anything that can be converted to Bytes. The final box name is the combination of key_prefix + key.

from algopy import BoxMap, Contract, Account, Txn, String

class MyContract(Contract):
    def __init__(self) -> None:
        self.my_map = BoxMap(Account, String, key_prefix=b"a_")

    def approval_program(self) -> bool:
        # Check if the box exists
        if Txn.sender in self.my_map:
            # Reassign the value
            self.my_map[Txn.sender] = String(" World")
        else:
            # Assign a new value
            self.my_map[Txn.sender] = String("Hello")
        # Read a value
        return self.my_map[Txn.sender] == String("Hello World")

If none of these abstractions suit your needs, you can use the box storage AVM ops to interact with box storage. These ops match closely to the opcodes available on the AVM.

For example:

op.Box.create(b"key", size)
op.Box.put(Txn.sender.bytes, answer_ids.bytes)
(votes, exists) = op.Box.get(Txn.sender.bytes)
op.Box.replace(TALLY_BOX_KEY, index, op.itob(current_vote + 1))

See the voting contract example for a real-world example that uses box storage.

Scratch storage

To use scratch storage you need to register the scratch storage that you want to use and then you can use the scratch storage AVM ops.

For example:

from algopy import Bytes, Contract, UInt64, op, urange

TWO = 2
TWENTY = 20


class MyContract(Contract, scratch_slots=(1, TWO, urange(3, TWENTY))):
    def approval_program(self) -> bool:
        op.Scratch.store(1, UInt64(5))

        op.Scratch.store(2, Bytes(b"Hello World"))

        for i in urange(3, 20):
            op.Scratch.store(i, i)

        assert op.Scratch.load_uint64(1) == UInt64(5)

        assert op.Scratch.load_bytes(2) == b"Hello World"

        assert op.Scratch.load_uint64(5) == UInt64(5)
        return True

    def clear_state_program(self) -> bool:
        return True