Skip to content

Latest commit

Β 

History

History
2640 lines (2166 loc) Β· 87.6 KB

lip-0051.md

File metadata and controls

2640 lines (2166 loc) Β· 87.6 KB
LIP: 0051
Title: Define state and state transitions of Token module
Author: Maxime Gagnebin <[email protected]>
        Grigorios Koumoutsos <[email protected]>
Discussions-To: https://research.lisk.com/t/define-state-and-state-transitions-of-token-module/295
Status: Draft
Type: Standards Track
Created: 2021-05-21
Updated: 2023-02-24

Abstract

This LIP introduces a Token module to be used in the Lisk ecosystem for minting, burning, and transferring tokens. This module allows any chain in the ecosystem to handle and transfer tokens in a coherent, secure, and controlled manner. In this LIP, the tokens handled are fungible.

Copyright

This LIP is licensed under the Creative Commons Zero 1.0 Universal.

Motivation

The Token module is composed of a state store definition used to store tokens in the state. To modify this store, we propose two commands: a token transfer command and a cross-chain token transfer command; as well as multiple functions to be used by other modules.

Interactions between custom modules and the Token module should only happen following the specified functions. Interacting with the token store via those functions allows sidechain developers to create custom modules and custom behavior without needing to ensure and test that all rules of the Token module are followed.

With the proposed interoperability solution for the Lisk ecosystem, we anticipate that multiple chains will create and distribute custom tokens. Those tokens can be used for a wide variety of reasons which are the choice of the sidechain developer.

Rationale

Technical Glossary

  • Native chain: With regards to a token, this is the chain where the token was minted.
  • Native tokens: With regards to a chain, all tokens minted on this chain.
  • Foreign chain: With regards to a token, all chains other than the native chain.

Token Identification and Interoperability

To identify tokens in the Lisk ecosystem, we introduce token identifiers in this proposal. An identifier will be unique among all tokens in the ecosystem. It is built from the concatenation of the chain ID of the chain minting the token and a local identifier, a 4 bytes value chosen when the token is initialized. The local identifier allows chains to define multiple custom tokens, each identified by their respective local ID. For example, a decentralized exchange could have a governance token (distributed in the genesis block) and a liquidity token (distributed to liquidity providers).

In particular, the LSK token is native to the Lisk mainchain which has chainID = 0x 00 00 00 00 (for the Lisk Mainnet), it is also the first (and only) token of this chain and has localID = 0x 00 00 00 00. This entails that the LSK token ID is 0x 00 00 00 00 00 00 00 00.

Supported Tokens

All chains are allowed to select which tokens their protocol supports. Supporting a token only implies that users of the chain can hold those tokens and handle them as specified in this LIP. It does not mean that the chain implements custom logic associated with those tokens.

The choice of supported tokens must abide by two rules: all chains must support their native tokens and all chains must support the LSK token. The supported tokens can be specified as part of the initial configuration of the Token module at the chain creation. For example:

  • A decentralized exchange could support all tokens.
  • A chain with a specific use case and no native token could only support the LSK token.
  • A chain with a specific use case and with a native token might only support the LSK token and their native token.
  • A gambling chain might support their native token, the LSK token and tokens from a selected group of oracle chains.

When receiving unsupported tokens from a cross-chain transfer, chains should return those tokens to the sending chain if the message fee was sufficient. The threshold on the message fee to return unsupported tokens is chosen to be the same as the interoperability threshold for returning CCMs for other errors. This threshold is set to be equal to the Lisk mainchain minimum fee.

Lastly, note that modifying the list of supported tokens would result in a fork of the chain. For this reason, the default behavior for Lisk sidechains would be to support all tokens.

Cross-chain Token Transfer

To allow cross-chain transfers of tokens, we define a specific command which makes use of the Interoperability module and creates a cross-chain message with the relevant information. When sending cross-chain tokens, it is crucial that every chain can correctly maintain escrow amounts of its native tokens across the ecosystem. In this way, the total supply of a token can never be increased by a foreign chain as the native chain only accepts as many tokens from a foreign chain as have been sent to it before.

Transfer To and From the Native Chain

These specifications only allow tokens to be transferred to and from their native chain. In particular, this means that a token minted on chain A cannot be transferred directly from chain B to chain C. This is required to allow the native chain to maintain correct escrowed amounts. The alternative would be to allow such transfer and require an additional message to be sent to the native chain to acknowledge the transfer. However the correctness of the escrowed amounts would rely on the processing of this additional information. Network delays could mean that this is only processed much later and that in the meantime users have been tricked into accepting tokens not backed by escrow.

Protocol Logic for Other Modules

The Token module provides several exposed methods, which are designed to allow a wide range of use cases while avoiding unexpected behaviors (such as unwanted minting or unlocking of tokens). The functions below are the main exposed methods of the Token module.

mint

This function allows a chain to mint a specified amount of native tokens. This function will increase the balance by the specified amount in the specified user substore and at the same time, increase the corresponding total token supply.

burn

This function allows a chain to destroy a specified amount of tokens. When burning tokens, this function will remove the specified amount of tokens from the user substore. In case the token burned is native, it also decreases the total supply of this token.

transfer

This function allows a chain to transfer tokens. When transferring tokens, this function will remove the tokens from the sender and add them to the recipient.

transferCrossChain

This function is used if a custom module needs to send tokens to another chain. It ensures that all amounts are correctly validated and that tokens are escrowed if necessary.

lock

This function is used to lock tokens held by a user. Locking tokens is done "module-wise", i.e., when locking tokens a module name has to be specified. This allows locked tokens to be managed more securely. For example, if a token is locked in a PoS module, then there is no risk that a bug in a custom HTLC module would unlock those tokens.

unlock

This function is used to unlock tokens previously locked. As for locking, the corresponding module ID needs to be specified in order to unlock the correct tokens. Notice that there is no protocol rule restricting different modules from unlocking tokens locked with a given module name, it is a protection allowing well written code to be more secure.

initializeUserAccount

This function creates an entry in the user substore.

recover

This function is called by the interoperability module whenever state recovery transaction for the Token module is executed. The amount of native tokens stored in the terminated chain can therefore be credited again to the user on the native chain. It should not be called by any other module.

payMessageFee

This function is called by the Interoperability module before sending cross-chain messages. It handles deducting the message fee from the account of the message sender. It should not be called by any other module.

beforeCrossChainCommandExecution

This function is called by the Interoperability module before applying cross-chain messages. It handles crediting the message fee to the account of the cross-chain update sender (relayer). It should not be called by any other module.

Use of Protocol Logics by Other Modules

As of writing this proposal, other modules exist in the Lisk protocol that make use of tokens. Those uses should be updated to call functions implemented by the Token module as defined in this proposal. This guarantees that those modules will not trigger potentially improper state changes. For example:

  • The staking process should use the lock and unlock function to lock and unlock staked tokens.
  • Block rewards should be assigned using the mint function.
  • The fee handling should use the lock function to lock the transaction fee in the sender account, the transfer function to transfer the remaining fee to the block forger and, on the Lisk mainchain, and the burn function to burn the used part of the fee.

Specification

Constants and Notations

The following constants are used throughout the document:

Name Type Value
Token Module Constants
MODULE_NAME_TOKEN string "token"
COMMAND_NAME_TRANSFER string "transfer"
COMMAND_NAME_CROSS_CHAIN_TRANSFER string "transferCrossChain"
CROSS_CHAIN_COMMAND_NAME_TRANSFER string TBD
CCM_STATUS_OK uint32 0
CCM_STATUS_TOKEN_NOT_SUPPORTED uint32 64
CCM_STATUS_PROTOCOL_VIOLATION uint32 65
ALL_SUPPORTED_TOKENS_KEY bytes EMPTY_BYTES
Token Store Constants
SUBSTORE_PREFIX_USER bytes 0x 00 00
SUBSTORE_PREFIX_SUPPLY bytes 0x 80 00
SUBSTORE_PREFIX_ESCROW bytes 0x c0 00
SUBSTORE_PREFIX_SUPPORTED_TOKENS bytes 0x e0 00
Configurable Constants Mainchain value
USER_ACCOUNT_INITIALIZATION_FEE uint64 5000000
ESCROW_ACCOUNT_INITIALIZATION_FEE uint64 5000000
General Constants
OWN_CHAIN_ID bytes chainID of the chain.
ADDRESS_LENGTH uint32 20
MIN_MODULE_NAME_LENGTH uint32 1
MAX_MODULE_NAME_LENGTH uint32 32
TOKEN_ID_LENGTH uint32 8
CHAIN_ID_LENGTH uint32 4
LOCAL_ID_LENGTH uint32 4
HASH_LENGTH uint32 32
MAX_DATA_LENGTH uint32 64
EMPTY_BYTES bytes ""

We further use the utility function getMainchainID() defined in LIP 0037 to obtain the chain ID of the mainchain.

Event Names and Results

Name Type Value Description
Names
EVENT_NAME_TRANSFER string "transfer" Used for events emitted during token transfers.
EVENT_NAME_TRANSFER_CROSS_CHAIN string "transferCrossChain" Used for events emitted during cross-chain token transfers.
EVENT_NAME_CCM_TRANSFER string "ccmTransfer" Used for events emitted during execution of cross-chain token transfer messages.
EVENT_NAME_MINT string "mint" Used for events emitted during calls to the mint function.
EVENT_NAME_BURN string "burn" Used for events emitted during calls to the burn function.
EVENT_NAME_LOCK string "lock" Used for events emitted during calls to the lock function.
EVENT_NAME_UNLOCK string "unlock" Used for events emitted during calls to the unlock function.
EVENT_NAME_INITIALIZE_TOKEN string TBD Used for events emitted during calls to the initializeToken function.
EVENT_NAME_INITIALIZE_USER_ACCOUNT string TBD Used for events emitted during initialization of user substore entry.
EVENT_NAME_INITIALIZE_ESCROW_ACCOUNT string TBD Used for events emitted during initialization of escrow substore entry.
EVENT_NAME_RECOVER string "recover" Used for events emitted during calls to the recover function.
EVENT_NAME_BEFORE_CCC_EXECUTION string "beforeCCCExecution" Used for events emitted during calls to the beforeCrossChainCommandExecution function.
EVENT_NAME_BEFORE_CCM_FORWARDING string "beforeCCMForwarding" Used for events emitted during calls to the beforeCrossChainMessageForwarding function.
EVENT_NAME_ALL_TOKENS_SUPPORTED string TBD Used for the allTokensSupported event
EVENT_NAME_ALL_TOKENS_SUPPORT_REMOVED string TBD Used for the allTokensSupportedRemoved event
EVENT_NAME_ALL_TOKENS_FROM_CHAIN_SUPPORTED string TBD Used for the allTokensFromChainSupported event
EVENT_NAME_ALL_TOKENS_FROM_CHAIN_SUPPORT_REMOVED string TBD Used for the allTokensFromChainSupportedRemoved event
EVENT_NAME_TOKEN_ID_SUPPORTED string TBD Used for the tokenIDSupported event
EVENT_NAME_TOKEN_ID_SUPPORT_REMOVED string TBD Used for the tokenIDSupportRemoved event
Result codes
RESULT_SUCCESSFUL uint32 0 Successful result code for events.
RESULT_INSUFFICIENT_BALANCE uint32 1 Used when there is not sufficient balance in the user substore.
DATA_TOO_LONG uint32 2 Used when the data input is too long.
INVALID_TOKEN_ID uint32 3 Used when the token is not native to either the sending chain, the receiving chain or the mainchain.
TOKEN_NOT_SUPPORTED uint32 4 Used when the token is not supported in the chain.
INSUFFICIENT_LOCKED_AMOUNT uint32 5 Used when the locked amount is insufficient.
RECOVER_FAIL_INVALID_INPUTS uint32 6 Used when the recover function fails because of invalid inputs.
RECOVER_FAIL_INSUFFICIENT_ESCROW uint32 7 Used when the recover function fails because of insufficient escrow.
MINT_FAIL_NON_NATIVE_TOKEN uint32 8 Used when the mint function fails because the token to be minted is not native to the chain.
MINT_FAIL_TOTAL_SUPPLY_TOO_BIG uint32 9 Used when the mint function fails because the total supply of the minted token would exceed uint64 range.
MINT_FAIL_TOKEN_NOT_INITIALIZED uint32 10 Used when the mint function fails because the token to be minted is not initialized.
TOKEN_ID_NOT_AVAILABLE uint32 11 Used when the specified token ID is not available.
TOKEN_ID_NOT_NATIVE uint32 12 Used when the specified token ID is not native to the chain.
INSUFFICIENT_ESCROW_BALANCE uint32 13 Used when the escrowed account does not have sufficient balance.

Type Definition

Name Type Validation Description
Address bytes Must be of length ADDRESS_LENGTH. Address of an account.
Module string Must be of length at least MIN_MODULE_NAME_LENGTH and at most MAX_MODULE_NAME_LENGTH. Used for identifying modules.
TokenID bytes Must be of length TOKEN_ID_LENGTH. Used for token identifiers.
ChainID bytes Must be of length CHAIN_ID_LENGTH. Used for chain identifiers.

Logic from Other Modules

Calling a function fct implemented in the Interoperability module is represented by Interoperability.fct(required inputs).

Token ID

Tokens are identified in the ecosystem by the token ID. The token ID is given by the concatenation of the ID of the token native chain and the token local ID: tokenID = chainID + localID.

For example, for Lisk Mainnet, the chain ID of the Lisk mainchain is 0x00000000 and further the local ID of the native mainchain token is always 0x00000000. Therefore, the LSK token on Mainnet is identified by the token ID 0x0000000000000000. Note that the Token module has a method getTokenIDLSK to obtain this token ID.

Token Module Store

The Token module store is separated into several substores.

User Substore

The Token module store contains entries dedicated to storing the balances of users for a given address and tokenID. The substore contains entries with:

  • The substore prefix is set to SUBSTORE_PREFIX_USER
  • Each store key is the (ADDRESS_LENGTH + TOKEN_ID_LENGTH)-byte concatenation of an address and a token ID: address + tokenID
  • Each store value is the serialization of an object following userStoreSchema.
userStoreSchema = {
    "type": "object",
    "required": ["availableBalance", "lockedBalances"],
    "properties": {
        "availableBalance": {
            "dataType": "uint64",
            "fieldNumber": 1
        },
        "lockedBalances": {
            "type": "array",
            "fieldNumber": 2,
            "items": {
                "type": "object",
                "required":[ "module", "amount" ],
                "properties": {
                    "module": {
                        "dataType": "string",
                        "minLength": MIN_MODULE_NAME_LENGTH,
                        "maxLength": MAX_MODULE_NAME_LENGTH,
                        "pattern": "^[a-zA-Z0-9]*$",
                        "fieldNumber": 1
                    },
                    "amount": {
                        "dataType": "uint64",
                        "fieldNumber": 2
                    }
                }
            }
        }
    }
}

In the above object, lockedBalances is always kept ordered in lexicographic order of module. This guarantees that serialization is done consistently across nodes maintaining the chain.

The lockedBalances array contains only elements with non-zero amounts. If any state transition would reduce the amount property of an element to zero, this element is removed from the array. If any state transition would increase the amount property for a non-existing module, then an entry for module is created with the amount set accordingly.

Supply Substore

The Token module store contains an entry dedicated to storing information about the total supply of native tokens. The substore contains entries with:

  • The substore prefix is set to SUBSTORE_PREFIX_SUPPLY.
  • Each store key is a token ID: tokenID.
  • Each store value is the serialization of an object following supplyStoreSchema.
supplyStoreSchema = {
    "type": "object",
    "required": ["totalSupply"],
    "properties": {
        "totalSupply": {
            "dataType": "uint64",
            "fieldNumber": 1
        }
    }
}

The default value for this substore is {"totalSupply": 0} serialized using supplyStoreSchema.

Escrow Substore

The Token module store contains an entry dedicated to storing information about native tokens which have been sent to another chain. The state contains an entry with:

  • The substore prefix is set to SUBSTORE_PREFIX_ESCROW.
  • Each store key is the identifier of the chain to which the tokens are escrowed, and the token ID of the escrowed token: escrowedChainID + tokenID.
  • Each store value is the serialization of an object following escrowStoreSchema.
escrowStoreSchema = {
    "type": "object",
    "required": ["amount"],
    "properties": {
        "amount" : {
            "dataType": "uint64",
            "fieldNumber": 1
        }
    }
}

Supported Tokens Substore

The Token module store contains an entry dedicated to storing information about supported tokens. The state contains an entry with:

  • The substore prefix is set to SUBSTORE_PREFIX_SUPPORTED_TOKENS.
  • Each store key is the identifier of the chain to which the supported tokens are native: chainID.
  • Each store value is the serialization of an object following supportedTokensSchema.
supportedTokensSchema = {
    "type": "object",
    "required": ["supportedTokenIDs"],
    "properties": {
        "supportedTokenIDs" : {
            "type": "array",
            "fieldNumber": 1,
            "items": {
                "dataType": "bytes",
                "length": TOKEN_ID_LENGTH
            }
        }
    }
}

If all tokens are supported, then the substore must contain an entry for the key ALL_SUPPORTED_TOKENS_KEY and no other entry. If not all tokens are supported, but all tokens from a chain with ID chainID are supported, then the substore must contain an entry for the key chainID with an empty array as value. For the native tokens and the LSK token which are always supported, no entries in the substore are added. That means, there is no entry with key OWN_CHAIN_ID and for entries with key getMainchainID(), the entry getTokenIDLSK() does not need to be included in the supported tokens array. For all entries in this substore, the properties supportedTokenIDs are kept in lexicographical order.

Store Notation

For the rest of this proposal:

  • Let userStore(address, tokenID) be the user substore entry with store key address + tokenID, deserialized using the userStoreSchema schema.
    • Let availableBalance(address, tokenID) be the availableBalance property of userStore(address, tokenID).
    • Let lockedAmount(address, module, tokenID) be the amount corresponding to the given module in the lockedBalances array of userStore(address, tokenID); if the array does not contain an entry for this module value, we assume that the amount is 0.
  • Let escrowStore(chainID, tokenID) be the escrow substore entry with store key chainID + tokenID, deserialized using the escrowStoreSchema schema.
    • Let escrowAmount(chainID, tokenID) be the amount value of escrowStore(chainID, tokenID).
  • Let supplyStore(tokenID) be the supply substore entry with store key tokenID, deserialized using the supplyStoreSchema schema.
    • Let totalSupply(tokenID) be the totalSupply property stored in supplyStore(tokenID).
  • Let supportedTokens(chainID) be the supported tokens substore entry with store key chainID, deserialized using the supportedTokensSchema schema.
  • Whenever a function calls property of a non-existing substore entry, an error is thrown. For example, if a state change attemps to increase availableBalance(address, tokenID) while userStore(address, tokenID) does not exist, this results to an error.

Commands

The module provides the following commands to modify token entries.

Token Transfer

Transactions executing this command have:

  • module = MODULE_NAME_TOKEN
  • command = COMMAND_NAME_TRANSFER
Parameters

The params property of token transfer transactions follows the schema transferParamsSchema.

transferParamsSchema = {
    "type": "object",
    "required": [
        "tokenID",
        "amount",
        "recipientAddress",
        "data"
    ],
    "properties": {
        "tokenID": {
            "dataType": "bytes",
            "length": TOKEN_ID_LENGTH,
            "fieldNumber": 1
        },
        "amount": {
            "dataType": "uint64",
            "fieldNumber": 2
        },
        "recipientAddress": {
            "dataType": "bytes",
            "length": ADDRESS_LENGTH,
            "fieldNumber": 3
        },
        "data": {
            "dataType": "string",
            "maxLength": MAX_DATA_LENGTH,
            "fieldNumber": 4
        }
    }
}
Verification
def verify(trs: Transaction):
    trsParams = decode(transferParamsSchema, trs.params)
    validateObjectSchema(transferParamsSchema, trsParams)

    senderAddress = SHA256(trs.senderPublicKey)[:ADDRESS_LENGTH] # Derive sender address from trs.senderPublicKey.

    if getAvailableBalance(senderAddress, trsParams.tokenID) < trsParams.amount:
        raise Exception("Insufficient balance")
Execution
def execute(trs: Transaction):
    trsParams = decode(transferParamsSchema, trs.params)
    
    senderAddress = SHA256(trs.senderPublicKey)[:ADDRESS_LENGTH] # Derive sender address from trs.senderPublicKey.
    tokenID = trsParams.tokenID
    recipientAddress = trsParams.recipientAddress
    amount = trsParams.amount

    if userStore(recipientAddress, tokenID) does not exist: # Initialize user substore if does not exist.
        initializeUserAccountInternal(
            address = recipientAddress,
            tokenID = tokenID
        )

    transferInternal(senderAddress, recipientAddress, tokenID, amount)    

Here, the initializeUserAccountInternal function creates an entry in the user substore and the transferInternal function debits sender's account and credits recipient's account.

Cross-chain Token Transfer

Transactions executing this command have:

  • module = MODULE_NAME_TOKEN
  • command = COMMAND_NAME_CROSS_CHAIN_TRANSFER
Parameters

The params property of cross-chain token transfer transactions must obey the following schema:

crossChainTransferParamsSchema = {
    "type": "object",
    "required": [
        "tokenID",
        "amount",
        "receivingChainID",
        "recipientAddress",
        "data",
        "messageFee",
        "messageFeeTokenID"
    ],
    "properties": {
        "tokenID": {
            "dataType": "bytes",
            "length": TOKEN_ID_LENGTH,
            "fieldNumber": 1
        },
        "amount": {
            "dataType": "uint64",
            "fieldNumber": 2
        },
        "receivingChainID": {
            "dataType": "bytes",
            "length": CHAIN_ID_LENGTH,
            "fieldNumber": 3
        },
        "recipientAddress": {
            "dataType": "bytes",
            "length": ADDRESS_LENGTH,
            "fieldNumber": 4
        },
        "data": {
            "dataType": "string",
            "maxLength": MAX_DATA_LENGTH,
            "fieldNumber": 5
        },
        "messageFee": {
            "dataType": "uint64",
            "fieldNumber": 6
        },
        "messageFeeTokenID": {
            "dataType": "bytes",
            "length": TOKEN_ID_LENGTH,
            "fieldNumber": 7
        }
    }
}
Verification
def verify(trs: Transaction) -> None:
    trsParams = decode(crossChainTransferParamsSchema, trs.params)
    validateObjectSchema(crossChainTransferParamsSchema, trsParams)

    senderAddress = SHA256(trs.senderPublicKey)[:ADDRESS_LENGTH]
    amount = trsParams.amount
    receivingChainID = trsParams.receivingChainID
    tokenID = trsParams.tokenID
    messageFee = trsParams.messageFee
    messageFeeTokenID = trsParams.messageFeeTokenID

    if receivingChainID == OWN_CHAIN_ID:
        raise Exception("Receiving chain cannot be the sending chain.")

    # Transfer is only possible for tokens native to either sending or receiving chain.
    tokenChainID = getChainID(tokenID) # The native chain of the token used for the transaction.
    if tokenChainID not in [OWN_CHAIN_ID, receivingChainID]:
        raise Exception("Token must be native to either the sending or the receiving chain.")

    if messageFeeTokenID != Interoperability.getMessageFeeTokenID(receivingChainID):
        raise Exception("Invalid message fee Token ID.")

    balanceChecks: dict[TokenID, uint64] = {}

    balanceChecks[tokenID] = amount

    if messageFeeTokenID in balanceChecks:
        balanceChecks[messageFeeTokenID] += messageFee
    else:
        balanceChecks[messageFeeTokenID] = messageFee

    for tkID in balanceChecks:
        if getAvailableBalance(senderAddress, tkID) < balanceChecks[tkID]:
            raise Exception("Insufficient balance")
Execution
def execute(trs: Transaction) -> None:
    trsParams = decode(crossChainTransferParamsSchema, trs.params)

    senderAddress = SHA256(trs.senderPublicKey)[:ADDRESS_LENGTH]
    tokenID = trsParams.tokenID
    amount = trsParams.amount
    receivingChainID = trsParams.receivingChainID
    recipientAddress = trsParams.recipientAddress
    messageFee = trsParams.messageFee,
    data = trsParams.data

    tokenChainID = getChainID(tokenID)    

    # Create entry in escrow substore if necessary, this also debits the sender's account.
    if tokenChainID == OWN_CHAIN_ID and escrowStore(receivingChainID, tokenID) does not exist:
        initializeEscrowAccountInternal(receivingChainID, tokenID)

    transferCrossChainInternal(senderAddress, tokenID, amount, receivingChainID, recipientAddress, messageFee, data)

Here, the initializeEscrowAccountInternal function creates an entry in the escrow substore and the transferCrossChainInternal function debits senders account and calls the interoperability module in order to create a CCM.

Cross-chain Commands

Cross-chain Token Transfer Messages

Cross-chain messages executing this cross-chain command have:

  • module = MODULE_NAME_TOKEN
  • crossChainCommand = CROSS_CHAIN_COMMAND_NAME_TRANSFER
CCM Parameters

The params property of cross-chain token transfer messages follows the schema crossChainTransferMessageParamsSchema.

crossChainTransferMessageParamsSchema = {
    "type": "object",
    "required": [
        "tokenID",
        "amount" ,   
        "senderAddress",
        "recipientAddress",
        "data"
    ],
    "properties": {
        "tokenID": {
            "dataType": "bytes",
            "length": TOKEN_ID_LENGTH,
            "fieldNumber": 1
        },
        "amount": {
            "dataType": "uint64",
            "fieldNumber": 2
        },
        "senderAddress": {
            "dataType": "bytes",
            "length": ADDRESS_LENGTH,
            "fieldNumber": 3
        },
        "recipientAddress": {
            "dataType": "bytes",
            "length": ADDRESS_LENGTH,
            "fieldNumber": 4
        },
        "data": {
            "dataType": "string",
            "maxLength": MAX_DATA_LENGTH,
            "fieldNumber": 5
        }
    }
}
Verification
def verify(trs: Transaction, ccm: CCM) -> None:
    ccmParams = decode(crossChainTransferMessageParamsSchema, ccm.params)
    validateObjectSchema(crossChainTransferMessageParamsSchema, ccmParams)

    tokenID = ccmParams.tokenID
    tokenChainID = getChainID(tokenID)
    sendingChainID = ccm.sendingChainID
    amount = ccmParams.amount

    if ccm.status > MAX_RESERVED_ERROR_STATUS:
        raise Exception("Invalid CCM status code")

    if tokenChainID not in [sendingChainID, OWN_CHAIN_ID]:
        raise Exception("Token must be native to either the sending or the receiving chain.")

    if tokenChainID == OWN_CHAIN_ID:
        if escrowStore(sendingChainID, tokenID) does not exist or escrowAmount(sendingChainID, tokenID) < amount:
            raise Exception("Insufficient balance in escrow account.")
Execution

When executing a cross-chain token transfer message ccm, the logic below is followed.

def execute(trs: Transaction, ccm: CCM)-> None:
    ccmParams = decode(crossChainTransferMessageParamsSchema, ccm.params)

    tokenID = ccmParams.tokenID
    tokenChainID = getChainID(tokenID)
    amount = ccmParams.amount
    recipientAddress = ccmParams.recipientAddress
    senderAddress = ccmParams.senderAddress
    sendingChainID = ccm.sendingChainID

    if isTokenSupported(tokenID) == False:
        emitPersistentEvent(
            module=MODULE_NAME_TOKEN,
            name=EVENT_NAME_CCM_TRANSFER,
            data={
                "senderAddress": senderAddress,
                "recipientAddress": recipientAddress,
                "tokenID": tokenID,
                "amount": amount,
                "receivingChainID": OWN_CHAIN_ID,
                "result": TOKEN_NOT_SUPPORTED
            },
            topics=[senderAddress, recipientAddress]
        )
        raise Exception("Non-supported token.")

    # If the message is returning, we return the tokens to the sender.
    if ccm.status != CCM_STATUS_OK:
        recipientAddress = senderAddress

    # Check if the receiving account exists
    # and deduct the account initialization fee (from the relayer) if it does not.
    if userStore(recipientAddress, tokenID) does not exist:
        initializeUserAccountInternal(recipientAddress, tokenID)

    if tokenChainID == OWN_CHAIN_ID:
        escrowAmount(sendingChainID, tokenID) -= amount

    availableBalance(recipientAddress, tokenID) += amount  
    emitEvent(
        module=MODULE_NAME_TOKEN,
        name=EVENT_NAME_CCM_TRANSFER,
        data={
            "senderAddress": senderAddress,
            "recipientAddress": recipientAddress,
            "tokenID": tokenID,
            "amount": amount,
            "receivingChainID": receivingChainID,
            "result": RESULT_SUCCESSFUL
        },
        topics=[senderAddress, recipientAddress]
    )

Events

transfer

This event has name = EVENT_NAME_TRANSFER. This event is emitted when the transfer function is called.

Topics
  • senderAddress: the address of the account sending the transfer.
  • recipientAddress: the address of the account receiving the transfer.
Data
transferEventDataSchema = {
    "type": "object",
    "required" = [
        "senderAddress",
        "recipientAddress",
        "tokenID",
        "amount",
        "result"
    ],
    "properties": {
        "senderAddress": {
            "dataType": "bytes",
            "length": ADDRESS_LENGTH,
            "fieldNumber": 1
        },
        "recipientAddress": {
            "dataType": "bytes",
            "length": ADDRESS_LENGTH,
            "fieldNumber": 2
        },
        "tokenID": {
            "dataType": "bytes",
            "length": TOKEN_ID_LENGTH,
            "fieldNumber": 3
        },
        "amount": {
            "dataType": "uint64",
            "fieldNumber": 4
        },
        "result": {
            "dataType": "uint32",
            "fieldNumber": 5
        }
    }
}

transferCrossChain

This event has name = EVENT_NAME_TRANSFER_CROSS_CHAIN.

Topics
  • senderAddress: the address of the account sending the transfer.
  • recipientAddress: the address of the account receiving the tokens.
  • receivingChainID: the chain ID where the tokens are transferred.
Data
transferCrossChainEventDataSchema = {
    "type": "object",
    "required" = [
        "senderAddress",
        "recipientAddress",
        "tokenID",
        "amount",
        "receivingChainID",
        "result"
    ],
    "properties": {
        "senderAddress": {
            "dataType": "bytes",
            "length": ADDRESS_LENGTH,
            "fieldNumber": 1
        },
        "recipientAddress": {
            "dataType": "bytes",
            "length": ADDRESS_LENGTH,
            "fieldNumber": 2
        },
        "tokenID": {
            "dataType": "bytes",
            "length": TOKEN_ID_LENGTH,
            "fieldNumber": 3
        },
        "amount": {
            "dataType": "uint64",
            "fieldNumber": 4
        },
        "receivingChainID": {
            "dataType": "bytes",
            "length": CHAIN_ID_LENGTH,
            "fieldNumber": 5
        },
        "result": {
            "dataType": "uint32",
            "fieldNumber": 6
        }
    }
}

ccmTransfer

This event has name = EVENT_NAME_CCM_TRANSFER. This event is emitted during the execution of cross-chain token transfer messages.

Topics
  • senderAddress: the address of the account sending the transfer.
  • recipientAddress: the address of the account receiving the tokens.
Data
ccmTransferEventDataSchema = {
    "type": "object",
    "required" = [
        "senderAddress",
        "recipientAddress",
        "tokenID",
        "amount",
        "receivingChainID",
        "result"
    ],
    "properties": {
        "senderAddress": {
            "dataType": "bytes",
            "length": ADDRESS_LENGTH,
            "fieldNumber": 1
        },
        "recipientAddress": {
            "dataType": "bytes",
            "length": ADDRESS_LENGTH,
            "fieldNumber": 2
        },
        "tokenID": {
            "dataType": "bytes",
            "length": TOKEN_ID_LENGTH,
            "fieldNumber": 3
        },
        "amount": {
            "dataType": "uint64",
            "fieldNumber": 4
        },
        "receivingChainID": {
            "dataType": "bytes",
            "length": CHAIN_ID_LENGTH,
            "fieldNumber": 5
        },
        "result": {
            "dataType": "uint32",
            "fieldNumber": 6
        }
    }
}

mint

This event has name = EVENT_NAME_MINT. This event is emitted when the mint function is called.

Topics
  • address: the address of the account to which to mint the tokens.
Data
mintEventDataSchema = {
    "type": "object",
    "required" = ["address", "tokenID", "amount", "result"],
    "properties": {
        "address": {
            "dataType": "bytes",
            "length": ADDRESS_LENGTH,
            "fieldNumber": 1
        },
        "tokenID": {
            "dataType": "bytes",
            "length": TOKEN_ID_LENGTH,
            "fieldNumber": 2
        },
        "amount": {
            "dataType": "uint64",
            "fieldNumber": 3
        },
        "result": {
            "dataType": "uint32",
            "fieldNumber": 4
        }
    }
}

burn

This event has name = EVENT_NAME_BURN. This event is emitted when the burn function is called.

Topics
  • address: the address of the account from which to burn the tokens.
Data
burnEventDataSchema = {
    "type": "object",
    "required" = [
        "address",
        "tokenID",
        "amount",
        "result"
    ],
    "properties": {
        "address": {
            "dataType": "bytes",
            "length": ADDRESS_LENGTH,
            "fieldNumber": 1
        },
        "tokenID": {
            "dataType": "bytes",
            "length": TOKEN_ID_LENGTH,
            "fieldNumber": 2
        },
        "amount": {
            "dataType": "uint64",
            "fieldNumber": 3
        },
        "result": {
            "dataType": "uint32",
            "fieldNumber": 4
        }
    }
}

lock

This event has name = EVENT_NAME_LOCK. This event is emitted when the lock function is called.

Topics
  • address: the address of the account from which to lock the tokens.
Data
lockEventDataSchema = {
    "type": "object",
    "required" = [
        "address",
        "module",
        "tokenID",
        "amount",
        "result"
    ],
    "properties": {
        "address": {
            "dataType": "bytes",
            "length": ADDRESS_LENGTH,
            "fieldNumber": 1
        },
        "module": {
            "dataType": "string",
            "minLength": MIN_MODULE_NAME_LENGTH,
            "maxLength": MAX_MODULE_NAME_LENGTH,
            "fieldNumber": 2
        },
        "tokenID": {
            "dataType": "bytes",
            "length": TOKEN_ID_LENGTH,
            "fieldNumber": 3
        },
        "amount": {
            "dataType": "uint64",
            "fieldNumber": 4
        },
        "result": {
            "dataType": "uint32",
            "fieldNumber": 5
        }
    }
}

unlock

This event has name = EVENT_NAME_UNLOCK. This event is emitted when the unlock function is called.

Topics
  • address: the address of the account from which to unlock the tokens.
  • tokenID: the token ID of the token to be unlocked.
Data
unlockEventDataSchema = {
    "type": "object",
    "required" = [
        "address",
        "module",
        "tokenID",
        "amount",
        "result"
    ],
    "properties": {
        "address": {
            "dataType": "bytes",
            "length": ADDRESS_LENGTH,
            "fieldNumber": 1
        },
        "module": {
            "dataType": "string",
            "minLength": MIN_MODULE_NAME_LENGTH,
            "maxLength": MAX_MODULE_NAME_LENGTH,
            "fieldNumber": 2
        },
        "tokenID": {
            "dataType": "bytes",
            "length": TOKEN_ID_LENGTH,
            "fieldNumber": 3
        },
        "amount": {
            "dataType": "uint64",
            "fieldNumber": 4
        },
        "result": {
            "dataType": "uint32",
            "fieldNumber": 5
        }
    }
}

initializeToken

This event has name = EVENT_NAME_INITIALIZE_TOKEN. This event is emitted when the initialize token function is called.

Topics
  • tokenID: the token ID to be initialized.
Data
initializeTokenEventDataSchema = {
    "type": "object",
    "required" = ["tokenID", "result"],
    "properties": {
        "tokenID": {
            "dataType": "bytes",
            "length": TOKEN_ID_LENGTH,
            "fieldNumber": 1
        },
        "result": {
            "dataType": "uint32",
            "fieldNumber": 2
        }
    }
}

initializeUserAccount

This event has name = EVENT_NAME_INITIALIZE_USER_ACCOUNT. This event is emitted when the initialized user store function is called.

Topics
  • address: the address of the user store to be initialized.
Data
initUserEventDataSchema = {
    "type": "object",
    "required" = ["address", "tokenID", "initializationFee", "result"],
    "properties": {
        "address": {
            "dataType": "bytes",
            "length": ADDRESS_LENGTH,
            "fieldNumber": 1
        },
        "tokenID": {
            "dataType": "bytes",
            "length": TOKEN_ID_LENGTH,
            "fieldNumber": 2
        },
        "initializationFee": {
            "dataType": "uint64",
            "fieldNumber": 4
        },
        "result": {
            "dataType": "uint32",
            "fieldNumber": 5
        }
    }
}

initializeEscrowAccount

This event has name = EVENT_NAME_INITIALIZE_ESCROW_ACCOUNT. This event is emitted when the initialized user store function is called.

Topics
  • chainID: the chain ID of the chain to be initialized in the escrow substore.
Data
initEscrowEventDataSchema = {
    "type": "object",
    "required" = ["chainID", "tokenID", "initializationFee" , "result"],
    "properties": {
        "chainID": {
            "dataType": "bytes",
            "length": CHAIN_ID_LENGTH,
            "fieldNumber": 1
        },
        "tokenID": {
            "dataType": "bytes",
            "length": TOKEN_ID_LENGTH,
            "fieldNumber": 2
        },
        "initializationFee": {
            "dataType": "uint64",
            "fieldNumber": 4
        },
        "result": {
            "dataType": "uint32",
            "fieldNumber": 5
        }
    }
}

recover

This event has name = EVENT_NAME_RECOVER. This event is emitted when a state recovery for the token module is called.

Topics
  • address: the address sending the recovery.
Data
recoverEventDataSchema = {
    "type": "object",
    "required" = [
        "terminatedChainID",
        "tokenID",
        "amount",
        "result"
    ],
    "properties": {
        "terminatedChainID": {
            "dataType": "bytes",
            "length": CHAIN_ID_LENGTH,
            "fieldNumber": 1
        },
        "tokenID": {
            "dataType": "bytes",
            "length": TOKEN_ID_LENGTH,
            "fieldNumber": 2
        },
        "amount": {
            "dataType": "uint64",
            "fieldNumber": 3
        },
        "result":{
            "dataType": "uint32",
            "fieldNumber": 4
        }
    }
}

beforeCCCExecution

This event has name = EVENT_NAME_BEFORE_CCC_EXECUTION. This event is emitted during calls of beforeCrossChainCommandExecution function.

Topics
  • relayerAddress: the address of the account that posted the cross-chain update transaction which includes this CCM.
  • messageFeeTokenID: the tokenID of the token used for message fees.
Data
beforeCCCExecutionEventDataSchema = {
    "type": "object",
    "required" = [
        "ccmID",
        "messageFeeTokenID",
        "relayerAddress",
        "result"
    ],
    "properties": {
        "ccmID": {
            "dataType": "bytes",
            "length": HASH_LENGTH,
            "fieldNumber": 1
        },
        "messageFeeTokenID": {
            "dataType": "bytes",
            "length": TOKEN_ID_LENGTH,
            "fieldNumber": 2
        },
        "relayerAddress": {
            "dataType": "bytes",
            "length": ADDRESS_LENGTH,
            "fieldNumber": 3
        },
        "result": {
            "dataType": "uint32",
            "fieldNumber": 4
        }
    }
}

beforeCCMForwarding

This event has name = EVENT_NAME_BEFORE_CCM_FORWARDING. This event is emitted during calls of beforeCrossChainMessageForwarding function; since this function is defined only in the mainchain, this event is also defined only in mainchain.

Topics
  • sendingChainID: the chain ID of the sending chain of the cross-chain message.
  • receivingChainID: the chain ID where the cross-chain message is forwarded to.
Data
beforeCCMForwardingEventDataSchema = {
    "type": "object",
    "required" = [
        "ccmID",
        "messageFeeTokenID",
        "result"
    ],
    "properties": {
        "ccmID": {
            "dataType": "bytes",
            "length": HASH_LENGTH,
            "fieldNumber": 1
        },
        "messageFeeTokenID": {
            "dataType": "bytes",
            "length": TOKEN_ID_LENGTH,
            "fieldNumber": 2
        },
        "result": {
            "dataType": "uint32",
            "fieldNumber": 3
        }
    }
}

allTokensSupported

This event has name = EVENT_NAME_ALL_TOKENS_SUPPORTED. This event is emitted when the supported tokens are updated to support all tokens.

Data
allTokensSupportedDataSchema = {
    "type": "object",
    "required": [],
    "properties": {}
}

allTokensSupportedRemoved

This event has name = EVENT_NAME_ALL_TOKENS_SUPPORT_REMOVED. This event is emitted when the support to all tokens is removed, e.g., when the function removeAllTokensSupport is called.

Data
allTokensSupportedRemovedDataSchema = {
    "type": "object",
    "required": [],
    "properties": {}
}

allTokensFromChainSupported

This event has name = EVENT_NAME_ALL_TOKENS_FROM_CHAIN_SUPPORTED. This event is emitted when the supported tokens are updated to support all tokens.

Topics
  • chainID: the ID of the chain for which all tokens are supported.
Data
allTokensFromChainSupportedEventDataSchema = {
    "type": "object",
    "required" = ["chainID"],
    "properties": {
        "chainID": {
            "dataType": "bytes",
            "length": CHAIN_ID_LENGTH,
            "fieldNumber": 1
        }
    }
}

allTokensFromChainSupportedRemoved

This event has name = EVENT_NAME_ALL_TOKENS_FROM_CHAIN_SUPPORT_REMOVED. This event is emitted when the support of all tokens of a chain is removed, e.g., when the function removeAllTokensSupportFromChainID is called.

Topics
  • chainID: the ID of the chain for which all tokens are supported.
Data

Same as in previous event, i.e., follow the allTokensFromChainSupportedEventDataSchema.

tokenIDSupported

This event has name = EVENT_NAME_TOKEN_ID_SUPPORTED. This event is emitted when a token ID is added to the supported tokens.

Topics
  • tokenID: the token ID of the supported token.
Data
tokenIDSupportedEventDataSchema = {
    "type": "object",
    "required" = ["tokenID"],
    "properties": {
        "legacyAddress": {
            "dataType": "bytes",
            "length": TOKEN_ID_LENGTH,
            "fieldNumber": 1
        }
    }
}

tokenIDSupportRemoved

This event has name = EVENT_NAME_TOKEN_ID_SUPPORT_REMOVED. This event is emitted when a token ID is removed from the supported tokens.

Topics
  • tokenID: the token ID for which the support is removed.
Data
tokenIDSupportRemovedEventDataSchema = {
    "type": "object",
    "required" = ["tokenID"],
    "properties": {
        "tokenID": {
            "dataType": "bytes",
            "length": TOKEN_ID_LENGTH,
            "fieldNumber": 1
        }
    }
}

Internal Functions

initializeEscrowAccountInternal

def initializeEscrowAccountInternal(
    chainID: ChainID,
    tokenID: TokenID
    ) -> None:

    # This may fail if there is not enough fee left.
    Fee.payFee(ESCROW_ACCOUNT_INITIALIZATION_FEE)

    create an escrow substore entry with
        key = chainID + tokenID
        value = encode(escrowStoreSchema, {"amount": 0})

    emitEvent(                                   
        module=MODULE_NAME_TOKEN,
        name=EVENT_NAME_INITIALIZE_ESCROW_ACCOUNT,
        data={
            "chainID": chainID,
            "tokenID": tokenID,
            "initializationFee": ESCROW_ACCOUNT_INITIALIZATION_FEE,
            "result": RESULT_SUCCESSFUL
        },
        topics=[chainID]
    )

initializeUserAccountInternal

def initializeUserAccountInternal(
    address: Address,
    tokenID: TokenID
    ) -> None:

    # This may fail if there is not enough fee left.
    Fee.payFee(USER_ACCOUNT_INITIALIZATION_FEE)

    create a user substore entry with
        key = address + tokenID
        value = encode(
            schema=userStoreSchema,
            object={
                "availableBalance": 0,
                "lockedBalances": []
            }
        )

    emitEvent(
        module=MODULE_NAME_TOKEN,
        name=EVENT_NAME_INITIALIZE_USER_ACCOUNT,
        data={
            "address": address,
            "tokenID": tokenID,
            "initializationFee":USER_ACCOUNT_INITIALIZATION_FEE,
            "result": RESULT_SUCCESSFUL
        },
        topics=[address]
    )

transferInternal

def transferInternal(
    senderAddress: Address,
    recipientAddress: Address,
    tokenID: TokenID,
    amount: uint64
) -> None:

    # Update substore.
    availableBalance(senderAddress, tokenID) -= amount
    availableBalance(recipientAddress, tokenID) += amount

    # Emit event.
    emitEvent(
        module=MODULE_NAME_TOKEN,
        name=EVENT_NAME_TRANSFER,
        data={
            "senderAddress": senderAddress,
            "recipientAddress": recipientAddress,
            "tokenID": tokenID,
            "amount": amount,
            "result": RESULT_SUCCESSFUL
        },
        topics=[senderAddress, recipientAddress]
    )

transferCrossChainInternal

def transferCrossChainInternal(
    senderAddress: Address,
    tokenID: TokenID,
    amount: uint64,
    receivingChainID: ChainID,
    recipientAddress: Address,
    messageFee: uint64,
    data: str
) -> None:

    # Debit the user's account.
    availableBalance(senderAddress, tokenID) -= amount

    # Escrow has to be updated only if the token is native to the chain.
    if getChainID(tokenID) == OWN_CHAIN_ID:       
        escrowAmount(receivingChainID, tokenID) += amount

    emitEvent(
        module=MODULE_NAME_TOKEN,
        name=EVENT_NAME_TRANSFER_CROSS_CHAIN,
        data={
            "senderAddress": senderAddress,
            "recipientAddress": recipientAddress,
            "tokenID": tokenID,
            "amount": amount,
            "receivingChainID": receivingChainID,
            "result": RESULT_SUCCESSFUL
        },
        topics=[senderAddress, recipientAddress, receivingChainID]
    )

    # Call interop. module to create CCM.
    Interoperability.send(
        sendingAddress=senderAddress,
        module=MODULE_NAME_TOKEN,
        crossChainCommand=CROSS_CHAIN_COMMAND_NAME_TRANSFER,
        receivingChainID=receivingChainID,
        fee=messageFee,
        params= encode(
            schema = crossChainTransferMessageParamsSchema,
            object = {  
                "tokenID": tokenID,
                "amount": amount,
                "senderAddress": senderAddress,
                "recipientAddress": recipientAddress,
                "data": data
            }
        )
    )

Protocol Logic for Other Modules

The Token module provides the following methods to modify the token state. Any other modules should use those to modify the token state. The token state should never be modified from outside the module without using one of the proposed functions as this could result in unexpected behavior and could cause an improper state transition.

isTokenSupported

def isTokenSupported(tokenID: TokenID) -> bool:
    chainID = getChainID(tokenID)

    # Native tokens and LSK token are always supported.
    if chainID == OWN_CHAIN_ID or tokenID == getTokenIDLSK():
        return True

    if supportedTokens(ALL_SUPPORTED_TOKENS_KEY) exists:
        return True

    if supportedTokens(chainID) exists:
        if supportedTokens(chainID).supportedTokenIDs == []:
            return True
        if tokenID is in supportedTokens(chainID).supportedTokenIDs:
            return True

    return False

getChainID

def getChainID(tokenID: TokenID) -> ChainID:
    return tokenID[:CHAIN_ID_LENGTH]

isNativeToken

def isNativeToken(tokenID: TokenID) -> bool:
    return getChainID(tokenID) == OWN_CHAIN_ID

userSubstoreExists

def userSubstoreExists(address: Address, tokenID: TokenID) -> bool:
    if userStore(address, tokenID) exists:
        return True
    else:
        return False

escrowSubstoreExists

def escrowSubstoreExists(chainID: ChainID, tokenID: TokenID) -> bool:
    if escrowStore(chainID, tokenID) exists:
        return True
    else:
        return False

getAvailableBalance

def getAvailableBalance(address: Address, tokenID: TokenID) -> uint64:
    if userSubstoreExists(address, tokenID):
        return availableBalance(address, tokenID)
    return 0

getLockedAmount

def getLockedAmount(address: Address, module: Module, tokenID: TokenID) -> uint64:
    return lockedAmount(address, module, tokenID)

getTotalSupply

def getTotalSupply(tokenID: TokenID) -> uint64:
     if totalSupply(tokenID) does not exist:
         raise Exception("Total supply entry does not exist.")
     return totalSupply(tokenID)

getEscrowedAmount

def getEscrowedAmount(escrowChainID: ChainID, tokenID: TokenID) -> uint64:
    if getChainID(tokenID) != OWN_CHAIN_ID:
        raise Exception("Token ID is not from the current chain")
    
    if escrowChainID == OWN_CHAIN_ID:
        raise Exception("Escrow is not defined for own chain.")

    if escrowStore(escrowChainID, tokenID) does not exist:
        return 0

    return escrowAmount(escrowChainID, tokenID)

getNextAvailableTokenID

def getNextAvailableTokenID() -> TokenID:
    if supply substore is non-empty: # Check if the maximum number of native tokens is reached.
        maxInitializedTokenID = the largest key in the supply substore
        intLocalID = int.from_bytes(maxInitializedTokenID[CHAIN_ID_LENGTH:])
        if intLocalID == 2^(8*LOCAL_ID_LENGTH) - 1:
            raise Exception("No more available token IDs.")
        nextTokenID = next byte value after maxInitializedTokenID in lexicographical order
        # This is equivalent to doing maxInitializedTokenID + 1 if the byte value is viewed as a big endian integer.
    else:
        # In this case the supply store is empty and we initialize the first token ID,
        # i.e. the OWN_CHAIN_ID concatenated with LOCAL_ID_LENGTH zero bytes.
        localID = LOCAL_ID_LENGTH bytes all set to 0x00
        nextTokenID = OWN_CHAIN_ID + localID

    return nextTokenID

isTokenIDAvailable

def isTokenIDAvailable(tokenID: TokenID) -> bool:
    if there exists an entry supplyStore(tokenID) in the supply substore:
        return False

    return True

getTokenIDLSK

def getTokenIDLSK() -> TokenID:
    # The local ID of the LSK token is 0x00000000.
    return getMainchainID() + b'\0'*4

initializeToken

def initializeToken(tokenID: TokenID) -> None:
    tokenChainID = getChainID(tokenID)
    if tokenChainID != OWN_CHAIN_ID:
        emitPersistentEvent(
            module=MODULE_NAME_TOKEN,
            name=EVENT_NAME_INITIALIZE_TOKEN,
            data={
                "tokenID": tokenID
                "result": TOKEN_ID_NOT_NATIVE
            },
            topics=[tokenID]
        )
        raise Exception('The specified token ID is not native to the current chain.')
    if isTokenIDAvailable(tokenID) == False:
            emitPersistentEvent(
                module=MODULE_NAME_TOKEN,
                name=EVENT_NAME_INITIALIZE_TOKEN,
                data={
                    "tokenID": tokenID
                    "result": TOKEN_ID_NOT_AVAILABLE
                },
                topics=[tokenID]
            )

        raise Exception('The specified token ID is not available.')

    # Create a supply substore entry for this tokenID.
    create an entry in the supply substore with
        key = tokenID
        value = encode(
            schema=supplyStoreSchema,
            object={"totalSupply": 0}
        )  

    emitEvent(
        module=MODULE_NAME_TOKEN,
        name=EVENT_NAME_INITIALIZE_TOKEN,
        data={
            "tokenID": tokenID,
            "result": RESULT_SUCCESSFUL
        },
        topics=[tokenID]
    )

mint

def mint(
    address: Address,
    tokenID: TokenID,
    amount: uint64
) -> None:
    # This function is only used to mint native tokens.
    if amount == 0:
        return

    if getChainID(tokenID) != OWN_CHAIN_ID:
        emitFailedMintEvent(address, tokenID, amount, MINT_FAIL_NON_NATIVE_TOKEN)
        raise Exception('Can not mint non-native tokens.')

    if supplyStore(tokenID) does not exist:
        emitFailedMintEvent(address, tokenID, amount, MINT_FAIL_TOKEN_NOT_INITIALIZED)
        raise Exception('No entry in supply substore for the specified token.')

    if totalSupply(tokenID) + amount >= 2**64:
        emitFailedMintEvent(address, tokenID, amount, MINT_FAIL_TOTAL_SUPPLY_TOO_BIG)
        raise Exception('Total supply of token outside allowed range.')

    if userStore(address, tokenID) does not exist:
        initializeUserAccountInternal(address, tokenID)

    availableBalance(address, tokenID) += amount
    totalSupply(tokenID) += amount
    emitEvent(
            module=MODULE_NAME_TOKEN,
            name=EVENT_NAME_MINT,
            data={
                "address": address,
                "tokenID": tokenID,
                "amount": amount,
                "result": RESULT_SUCCESSFUL
            },
            topics=[address]
    )

def emitFailedMintEvent(
    address: Address,
    tokenID: tokenID,
    amount: uint64,
    result: uint32
    ) -> None:

    emitPersistentEvent(
        module=MODULE_NAME_TOKEN,
        name=EVENT_NAME_MINT,
        data={
            "address": address,
            "tokenID": tokenID,
            "amount": amount,
            "result": result
        },
        topics=[address]
    )

burn

def burn(
    address: Address,
    tokenID: TokenID,
    amount: uint64
) -> None:
    if amount == 0:
        return

    if getAvailableBalance(address, tokenID) < amount:
        emitPersistentEvent(
            module=MODULE_NAME_TOKEN,
            name=EVENT_NAME_BURN,
            data={
                "address": address,
                "tokenID": tokenID,
                "amount": amount,
                "result": RESULT_INSUFFICIENT_BALANCE
                },
            topics=[address]         
        )
        raise Exception("Insufficient available balance.")

    availableBalance(address, tokenID) -= amount   

    # If token is native, update total supply.
    tokenChainID = getChainID(tokenID)
    if tokenChainID == OWN_CHAIN_ID:
        totalSupply(tokenID) -= amount

    emitEvent(
        module=MODULE_NAME_TOKEN,
        name=EVENT_NAME_BURN,
        data={
            "address": address,
            "tokenID": tokenID,
            "amount": amount,
            "result": RESULT_SUCCESSFUL
            },
        topics=[address]
    )

initializeUserAccount

def initializeUserAccount(
    address: Address,
    tokenID: TokenID
) -> None:

    # If the store entry already exists, nothing is done.
    if userStore(address, tokenID) exists:
        return

    initializeUserAccountInternal(address, tokenID)

initializeEscrowAccount

def initializeEscrowAccount(
    chainID: ChainID,
    tokenID: TokenID
) -> None:

    # Escrow can be initialized only for native tokens.
    if OWN_CHAIN_ID != getChainID(tokenID):
        raise Exception('Token is not native, cannot create an entry in escrow substore')

    if chainID == OWN_CHAIN_ID:
        raise Exception("Can not initialize escrow for own chain.") 

    # If the store entry already exists, nothing is done.
    if escrowStore(chainID, tokenID) exists:
        return

    initializeEscrowAccountInternal(chainID, tokenID)

transfer

def transfer(
    senderAddress: Address,
    recipientAddress: Address,
    tokenID: TokenID,
    amount: uint64
) -> None:

    ### Checks ###
    if getAvailableBalance(senderAddress, tokenID) < amount:
        emitFailedTransferEvent(senderAddress, recipientAddress, tokenID, amount, RESULT_INSUFFICIENT_BALANCE)
        raise Exception("Insufficient sender available balance.")

    if userStore(recipientAddress, tokenID) does not exist:
        initializeUserAccountInternal(
            address = recipientAddress,
            tokenID = tokenID
        )
    ### End of checks ###

    transferInternal(senderAddress, recipientAddress, tokenID, amount)

def emitFailedTransferEvent(
    senderAddress: Address,
    recipientAddress: Address,
    tokenID: TokenID,
    amount: uint64,
    result: uint32) -> None:

    emitPersistentEvent(
        module=MODULE_NAME_TOKEN,
        name=EVENT_NAME_TRANSFER,
        data={
            "senderAddress": senderAddress,
            "recipientAddress": recipientAddress,
            "tokenID": tokenID,
            "amount": amount,
            "result": result
        },
        topics=[senderAddress, recipientAddress]
    )

transferCrossChain

def transferCrossChain(
    senderAddress: Address,
    tokenID: TokenID,
    amount: uint64,
    receivingChainID: ChainID,
    recipientAddress: Address,
    messageFee: uint64,
    data: str
) -> None:

    tokenChainID = getChainID(tokenID)

    ### Checks ###

    # Basic input checks.
    if len(data) > MAX_DATA_LENGTH:
        emitFailedCrossChainTransferEvent(senderAddress, tokenID, amount, receivingChainID, recipientAddress, DATA_TOO_LONG)
        raise Exception("Data field too long.")

    if receivingChainID == OWN_CHAIN_ID:
        raise Exception("Receiving chain cannot be the sending chain.")

    # Balance checks.
    balanceChecks: dict[TokenID, uint64] = {}
    balanceChecks[tokenID] = amount

    messageFeeTokenID = Interoperability.getMessageFeeTokenID(receivingChainID)
    if messageFeeTokenID in balanceChecks:
        balanceChecks[messageFeeTokenID] += messageFee
    else:
        balanceChecks[messageFeeTokenID] = messageFee

    for tkID in balanceChecks:
        if getAvailableBalance(senderAddress, tkID) < balanceChecks[tkID]:
            emitFailedCrossChainTransferEvent(senderAddress, tkID, amount, receivingChainID, recipientAddress, RESULT_INSUFFICIENT_BALANCE)
            raise Exception("Insufficient sender available balance")

    # Transfer is only possible for tokens native to either sending or receiving chain.
    if tokenChainID not in [OWN_CHAIN_ID, receivingChainID]:
        emitFailedCrossChainTransferEvent(senderAddress, tokenID, balanceChecks[tkID], receivingChainID, recipientAddress, INVALID_TOKEN_ID)
        raise Exception("Token must be native to either the sending or the receiving chain.")

    # Check if there is escrow substore entry.
    if tokenChainID == OWN_CHAIN_ID and escrowStore(receivingChainID, tokenID) does not exist:
        initializeEscrowAccountInternal(receivingChainID, tokenID)

    ### End of checks ###

    transferCrossChainInternal(senderAddress, tokenID, amount, receivingChainID, recipientAddress, messageFee, data)

def emitFailedCrossChainTransferEvent(
    senderAddress: Address,
    tokenID: TokenID,
    amount: uint64,
    receivingChainID: ChainID,
    recipientAddress: Address,
    result: uint32) -> None:

    emitPersistentEvent(
        module=MODULE_NAME_TOKEN,
        name=EVENT_NAME_TRANSFER_CROSS_CHAIN,
        data={
            "senderAddress": senderAddress,
            "recipientAddress": recipientAddress,
            "tokenID": tokenID,
            "amount": amount,
            "receivingChainID": receivingChainID,
            "result": result
        },
        topics=[senderAddress, recipientAddress, receivingChainID]
    )

lock

def lock(
    address: Address,
    module: Module,
    tokenID: TokenID,
    amount: uint64
) -> None:
    if amount == 0:
        return

    if getAvailableBalance(address, tokenID) < amount:
        emitPersistentEvent(
            module=MODULE_NAME_TOKEN,
            name=EVENT_NAME_LOCK,
            data={
                "address": address,
                "module": module,
                "tokenID": tokenID,
                "amount": amount,
                "result": RESULT_INSUFFICIENT_BALANCE
                },
            topics=[address]         
        )
        raise Exception("Insufficient available balance.")

    availableBalance(address, tokenID) -= amount
    lockedAmount(address, module, tokenID) += amount
    emitEvent(
        module=MODULE_NAME_TOKEN,
        name=EVENT_NAME_LOCK,
        data={
            "address": address,
            "module": module,
            "tokenID": tokenID,
            "amount": amount,
            "result": RESULT_SUCCESSFUL
        },
        topics=[address]
    )

unlock

def unlock(
    address: Address,
    module: Module,
    tokenID: TokenID,
    amount: uint64
) -> None:
    if amount == 0:
        return

    if lockedAmount(address, module, tokenID) < amount:
        emitPersistentEvent(
            module=MODULE_NAME_TOKEN,
            name=EVENT_NAME_UNLOCK,
            data={
                "address": address,
                "module": module,
                "tokenID": tokenID,
                "amount": amount,
                "result": INSUFFICIENT_LOCKED_AMOUNT
            },
            topics=[address]
        )
        raise Exception("Insufficient locked amount.")

    lockedAmount(address, module, tokenID) -= amount
    availableBalance(address, tokenID) += amount
    emitEvent(
        module=MODULE_NAME_TOKEN,
        name=EVENT_NAME_UNLOCK,
        data={
            "address": address,
            "module": module,
            "tokenID": tokenID,
            "amount": amount,
            "result": RESULT_SUCCESSFUL
        },
        topics=[address]
    )

payMessageFee

def payMessageFee(payFromAddress: Address, fee: uint64, receivingChainID: ChainID) -> None:
    messageFeeTokenID = Interoperability.getMessageFeeTokenID(receivingChainID)

    # Sender should have enough balance to pay the message fees.
    if getAvailableBalance(payFromAddress, messageFeeTokenID) < fee:
        raise Exception("Insufficient balance for the message fee.")

    # Pay the fee.
    availableBalance(payFromAddress, messageFeeTokenID) -= fee

    # If the chain is the fee token native chain, update escrow account.
    if getChainID(messageFeeTokenID) == OWN_CHAIN_ID:
        # Notice an escrow account for message fee token is guaranteed to exist
        # since it is initalized in partner chain registration.
        escrowAmount(receivingChainID, messageFeeTokenID) += fee

recover

def recover(
    terminatedChainID: ChainID,
    substorePrefix: bytes,
    storeKey: bytes,
    storeValue: bytes
) -> None:
    if (
        substorePrefix != SUBSTORE_PREFIX_USER
        or len(storeKey) != ADDRESS_LENGTH + TOKEN_ID_LENGTH
        or storeValue cannot be deserialized using userStoreSchema
    ):

        emitFailedRecoverEvent(terminatedChainID, storeKey[:ADDRESS_LENGTH], EMPTY_BYTES, 0, RECOVER_FAIL_INVALID_INPUTS)
        raise Exception("Invalid arguments.")

    address = storeKey[:ADDRESS_LENGTH]
    tokenID = storeKey[ADDRESS_LENGTH:ADDRESS_LENGTH + TOKEN_ID_LENGTH]
    account = decode(schema=userStoreSchema, object=storeValue)
    totalAmount = sum of availableBalance and all locked amounts of account

    if (
        getChainID(tokenID) != OWN_CHAIN_ID
        or getEscrowedAmount(terminatedChainID, tokenID) < totalAmount
    ):
        emitFailedRecoverEvent(terminatedChainID, address, tokenID, totalAmount, RECOVER_FAIL_INSUFFICIENT_ESCROW)
        raise Exception("Insufficient escrow amount.")

    escrowAmount(terminatedChainID, tokenID) -= totalAmount

    availableBalance(address, tokenID) += totalAmount

    emitEvent(
        module=MODULE_NAME_TOKEN,
        name=EVENT_NAME_RECOVER,
        data={
            "terminatedChainID": terminatedChainID,
            "tokenID": tokenID,
            "amount": totalAmount,
            "result": RESULT_SUCCESSFUL
            },
        topics=[address]
    )

def emitFailedRecoverEvent(
    terminatedChainID: ChainID,
    address: Address,
    tokenID: tokenID,
    amount: uint64,
    result: uint32
) -> None:

    emitPersistentEvent(
        module=MODULE_NAME_TOKEN,
        name=EVENT_NAME_RECOVER,
        data={
            "terminatedChainID": terminatedChainID,
            "tokenID": tokenID,
            "amount": amount,
            "result": result
            },
        topics=[address]
    )

supportAllTokens

This function updates the supported tokens substore to support all tokens of the Lisk ecosystem.

def supportAllTokens() -> None:
    remove all entries from the supported tokens substore

    create an entry in the supported tokens substore with
        key = ALL_SUPPORTED_TOKENS_KEY
        value = encode(
            schema=supportedTokensSchema,
            object={"supportedTokenIDs": []}
        )
    emitEvent(
        module=MODULE_NAME_TOKEN,
        name=EVENT_NAME_ALL_TOKENS_SUPPORTED,
        data={},
        topics=[]
    )

removeAllTokensSupport

This function removes the support from all tokens. After calling this function the supported tokens substore becomes empty, therefore the only remaining supported tokens are the ones native to the chain and the LSK token.

def removeAllTokensSupport() -> None:
    remove all entries from the supported tokens substore

    emitEvent(
        module=MODULE_NAME_TOKEN,
        name=EVENT_NAME_ALL_TOKENS_SUPPORT_REMOVED,
        data={},
        topics=[]
    )

supportAllTokensFromChainID

This function updates the supported tokens substore to support all tokens native to a specific chain.

def supportAllTokensFromChainID(chainID: ChainID) -> None:
    if there exists entry in the supported tokens substore with key == ALL_SUPPORTED_TOKENS_KEY:
        return

    # All tokens native to own chain are supported.
    if chainID == OWN_CHAIN_ID:
        return

    if supportedTokens(chainID) exists:
        # Set the value of the store entry to the empty array.
        supportedTokens(chainID) = {"supportedTokenIDs": []}

    else:
        create an entry in the supported tokens substore with
            key = chainID
            value = encode(
                schema=supportedTokensSchema,
                object={"supportedTokenIDs": []}
            )
    emitEvent(
        module=MODULE_NAME_TOKEN,
        name=EVENT_NAME_ALL_TOKENS_FROM_CHAIN_SUPPORTED,
        data={"chainID": chainID},
        topics=[chainID]
    )

removeAllTokensSupportFromChainID

This function is called to remove support for all tokens of a specified chain.

def removeAllTokensSupportFromChainID(chainID: ChainID) -> None:
    if there exists entry in the supported tokens substore with key == ALL_SUPPORTED_TOKENS_KEY:
        raise Exception('Invalid operation. All tokens from all chains are supported.')

    if chainID == OWN_CHAIN_ID:
        raise Exception('Invalid operation. All tokens from all the specified chain should be supported.')

    if supportedTokens(chainID) does not exist:
        return

    delete entry supportedTokens(chainID) from the supported tokens substore

    emitEvent(
        module=MODULE_NAME_TOKEN,
        name=EVENT_NAME_ALL_TOKENS_FROM_CHAIN_SUPPORT_REMOVED,
        data={"chainID": chainID},
        topics=[chainID]
    )

supportTokenID

def supportTokenID(tokenID: TokenID) -> None:
    chainID = getChainID(tokenID)
    # All tokens are supported.
    if there exists entry in the supported tokens substore with key == ALL_SUPPORTED_TOKENS_KEY:
        return

    # Token already supported.
    if chainID = OWN_CHAIN_ID or tokenID = getTokenIDLSK():
        return

    # All tokens from chain are supported.
    if supportedTokens(chainID) exists:
        if supportedTokens(chainID).supportedTokenIDs == []:
            return

        add tokenID to supportedTokens(chainID).supportedTokenIDs, maintaining the array in lexicographical order

    else:
        create an entry in the supported tokens substore with
        key = chainID
        value = encode(
            schema=supportedTokensSchema,
            object={"supportedTokenIDs": [tokenID]}
        )

    emitEvent(
        module=MODULE_NAME_TOKEN,
        name=EVENT_NAME_TOKEN_ID_SUPPORTED,
        data={"tokenID": tokenID},
        topics=[tokenID]
    )

removeSupport

This function is called to remove support from a specified token. In case all tokens are supported or all tokens of the token's native chain are supported, this function raises an exception.

def removeSupport(tokenID: TokenID) -> None:
    chainID = getChainID(tokenID)
    if supportedTokens(ALL_SUPPORTED_TOKENS_KEY) exists:
        raise Exception('All tokens are supported.')

    if tokenID = getTokenIDLSK() or chainID == OWN_CHAIN_ID:
        raise Exception('Can not remove support for the specified token.')

    if supportedTokens(chainID) exists:
        if supportedTokens(chainID).supportedTokenIDs == []: # All tokens from this chain are supported.
            raise Exception('All tokens from the specified chain are supported.')

        if there exist an item in array supportedTokens(chainID).supportedTokenIDs with value tokenID: # Remove token from supported tokens.
            remove tokenID from supportedTokens(chainID).supportedTokenIDs
            if supportedTokens(chainID).supportedTokenIDs is empty: # No tokens from this chain are supported after the last deletion.
                remove supportedTokens(chainID) from the supported tokens substore

    emitEvent(
        module=MODULE_NAME_TOKEN,
        name=EVENT_NAME_TOKEN_ID_SUPPORT_REMOVED,
        data={"tokenID": tokenID},
        topics=[tokenID]
    )

Genesis Block Processing

Genesis Assets Schema

genesisTokenStoreSchema = {
    "type": "object",
    "required": [
        "userSubstore",
        "supplySubstore",
        "escrowSubstore",
        "supportedTokensSubstore"
    ],
    "properties": {
        "userSubstore": {
            "type": "array",
            "fieldNumber": 1,
            "items": {
                "type": "object",
                "required": [
                    "address",
                    "tokenID",
                    "availableBalance",
                    "lockedBalances"
                ],
                "properties": {
                    "address": {
                        "dataType": "bytes",
                        "length": ADDRESS_LENGTH,
                        "fieldNumber": 1
                    },
                    "tokenID": {
                        "dataType": "bytes",
                        "length": TOKEN_ID_LENGTH,
                        "fieldNumber": 2,
                    },
                    "availableBalance": {
                        "dataType": "uint64",
                        "fieldNumber": 3
                    },
                    "lockedBalances": {
                        "type": "array",
                        "fieldNumber": 4,
                        "items": {
                            "type": "object",
                            "required": ["module", "amount"],
                            "properties": {
                                "module": {
                                    "dataType": "string",
                                    "minLength": MIN_MODULE_NAME_LENGTH,
                                    "maxLength": MAX_MODULE_NAME_LENGTH,
                                    "fieldNumber": 1
                                },
                                "amount": {
                                    "dataType": "uint64",
                                    "fieldNumber": 2
                                }
                            }
                        }
                    }
                }
            }
        },
        "supplySubstore": {
            "type": "array",
            "fieldNumber": 2,
            "items": {
                "type": "object",
                "required": ["tokenID", "totalSupply"],
                "properties": {
                    "tokenID": {
                        "dataType": "bytes",
                        "length": TOKEN_ID_LENGTH,
                        "fieldNumber": 1
                    },
                    "totalSupply": {
                        "dataType": "uint64",
                        "fieldNumber": 1
                    }
                }
            }
        },
        "escrowSubstore": {
            "type": "array",
            "fieldNumber": 3,
            "items": {
                "type": "object",
                "required": ["escrowedChainID", "tokenID", "amount"],
                "properties": {
                    "escrowedChainID": {
                        "dataType": "bytes",
                        "length": CHAIN_ID_LENGTH,
                        "fieldNumber": 1
                    },
                    "tokenID": {
                        "dataType": "bytes",
                        "length": TOKEN_ID_LENGTH,
                        "fieldNumber": 2
                    },
                    "amount": {
                        "dataType": "uint64",
                        "fieldNumber": 3
                    }
                }
            }
        },
        "supportedTokensSubstore": {
            "type": "array",
            "fieldNumber": 4,
            "items": {
                "type": object,
                "required": ["chainID", "supportedTokenIDs"],
                "properties": {
                    "chainID": {
                        "dataType": "bytes",
                        "length": CHAIN_ID_LENGTH,
                        "fieldNumber": 1
                    },
                    "supportedTokenIDs" : {
                        "type": "array",
                        "fieldNumber": 2,
                        "items": {
                            "dataType": "bytes",
                            "length": TOKEN_ID_LENGTH
                        }
                    }
                }            

            }
        }
    }
}

Genesis State Initialization

During the genesis state initialization stage, the following steps are executed. If any step fails, the block is discarded and has no further effect.

Let genesisBlockAssetBytes be the data bytes included in the block assets for the Token module and let genesisBlockAssetObject be the deserialization of genesisBlockAssetBytes according to the genesisTokenStoreSchema schema, given above.

  • Initial checks on the properties of genesisBlockAssetObject:

    • Across all elements of the userSubstore array, the pairs address, tokenID must be unique (each pair appears at most once, though a given address can appear multiple times with different token IDs).
    • The userSubstore array must be in lexicographical order of address. For a given address, the entries must be in lexicographic order of tokenID.
    • For each address, tokenID pair:
      • The values used as module in the lockedBalances array must be unique.
      • The lockedBalances array must be in lexicographic order of module name.
      • All elements of the lockedBalances array must have amount != 0.
    • Across all element of the supplySubstore array, all values given for tokenID must be unique.
    • The supplySubstore array must be in ascending order of tokenID.
    • Across all elements of the escrowSubstore array, the tokenID must be unique (it appears at most once).
    • The escrowSubstore array must be in ascending order of tokenID.
    • The supportedTokensSubstore array must be in lexicographical order of chainID. For each entry of this array, the supportedTokenIDs array should be in lexicographic order.
  • For each entry userEntry in genesisBlockAssetObject.userSubstore, create an entry in the user substore with:

storeKey = userEntry.address + userEntry.tokenID
storeValue = encode(
    schema=userStoreSchema,
    object={
        "availableBalance": userEntry.availableBalance,
        "lockedBalances": [{
            "module": lockedBalance.module,
            "amount": lockedBalance.amount
        } for each lockedBalance in userEntry.lockedBalances]
)
  • For each entry supplyEntry in genesisBlockAssetObject.supplySubstore, create an entry in the supply substore with:
storeKey = supplyEntry.tokenID
storeValue = encode(
    schema=supplyStoreSchema,
    object={"totalSupply": supplyEntry.totalSupply}
)
  • For each entry escrowEntry in genesisBlockAssetObject.escrowSubstore, create an entry in the escrow substore with:
storeKey = escrowEntry.escrowedChainID + escrowEntry.tokenID
storeValue = encode(
    schema=escrowStoreSchema,
    object={"amount": escrowEntry.amount}
)
  • For each entry supportedTokensEntry in genesisBlockAssetObject.supportedTokensSubstore, create an entry in the supported tokens substore with:
storeKey = supportedTokensEntry.chainID
storeValue = encode(
    schema=supportedTokensSchema,
    object={"supportedTokenIDs": [tkID for each tkID in supportedTokensEntry.supportedTokenIDs]}
)

Once the module store is initialized, its validity is attested via the two checks below.

  • Check that for each native token the total supply is correct. This can be done by checking that the function below returns True.
    def validateSupplyStoreEntries() -> bool:
        computedSupplies = {}
        for (storeKey, storeValue) in user substore:
            tokenID = storeKey[ADDRESS_LENGTH:ADDRESS_LENGTH + TOKEN_ID_LENGTH] # Part of the key corresponding to the token ID of the token.
            if getChainID(tokenID) == OWN_CHAIN_ID:
                computedSupplies[tokenID] += sum of availableBalance and all locked amounts of storeValue
    
        for (storeKey, storeValue) in escrow substore:
            tokenID = storeKey[CHAIN_ID_LENGTH:] # Part of the key corresponding to the token ID of the token.
            computedSupplies[tokenID] += storeValue.amount
    
        if computedSupplies[tokenID] >= 2^64 for any tokenID:
            return False
    
        storedSupplies = {}
        for (storeKey, storeValue) in supply substore:
            tokenID = storeKey
            storedSupplies[tokenID] = storeValue.amount
    
        # Check if both dictionary are coherent.
        for tokenID in computedSupplies:
            if storedSupplies[tokenID] does not exist or computedSupplies[tokenID] != storedSupplies[tokenID]:
                return False
    
        for tokenID in storedSupplies:
            if computedSupplies[tokenID] does not exist and storedSupplies[tokenID] != 0:
                return False
    
        return True

Cross-chain Update Processing

The following steps are executed as part of the execution of a cross-chain update command, see LIP 0049 and LIP 0053 for details. For all functions below, in case the chain is native for the token used for message fees, we can safely assume that the corresponding escrow account exists, since it is initialized during partner chain registration.

verifyCrossChainMessage

def verifyCrossChainMessage(trs: Transaction, ccm: CCM) -> None:
    # This is getTokenIDLSK() for channels with the mainchain.
    messageFeeTokenID = Interoperability.getMessageFeeTokenID(ccm.sendingChainID)
    if getChainID(messageFeeTokenID) == OWN_CHAIN_ID:
        if escrowAmount(ccm.sendingChainID, messageFeeTokenID) < ccm.fee:
            raise Exception("Insufficient escrow amount.")

beforeCrossChainCommandExecution

def beforeCrossChainCommandExecution(trs: Transaction, ccm: CCM) -> None:
    relayerAddress = sha256(trs.senderPublicKey)[:ADDRESS_LENGTH]

    messageFeeTokenID = Interoperability.getMessageFeeTokenID(ccm.receivingChainID)
    # If the chain is the fee token native chain, un-escrow before assigning fee to relayer.
    if getChainID(messageFeeTokenID) == OWN_CHAIN_ID:
        # This should never fail since it is checked in verifyCrossChainMessage.
        if escrowAmount(ccm.sendingChainID, messageFeeTokenID) < ccm.fee:
            emitPersistentEvent(
                module=MODULE_NAME_TOKEN,
                name=EVENT_NAME_BEFORE_CCC_EXECUTION,
                data={
                    "ccmID": Interoperability.encodeCCM(ccm),
                    "messageFeeTokenID": messageFeeTokenID,
                    "relayerAddress": relayerAddress,
                    "result": INSUFFICIENT_ESCROW_BALANCE
                },
                topics=[relayerAddress, messageFeeTokenID]
            )
            raise Exception("Insufficient balance in the sending chain for the message fee.")

        escrowAmount(ccm.sendingChainID, messageFeeTokenID) -= ccm.fee

    # Notice that the relayer account is guaranteed to exist
    # since we initialize it at the beginning of the CCU execution.
    availableBalance(relayerAddress, messageFeeTokenID) += ccm.fee

beforeCrossChainMessageForwarding

This function is defined only in the Lisk mainchain.

def beforeCrossChainMessageForwarding(trs: Transaction, ccm: CCM) -> None:
    # This should never fail since it is checked in verifyCrossChainMessage.
    messageFeeTokenID = Interoperability.getMessageFeeTokenID(ccm.receivingChainID) # Always equal to getTokenIDLSK().
    if escrowAmount(ccm.sendingChainID, messageFeeTokenID) < ccm.fee:
        emitPersistentEvent(
            module=MODULE_NAME_TOKEN,
            name=EVENT_NAME_BEFORE_CCM_FORWARDING,
            data={
                "ccmID": Interoperability.encodeCCM(ccm),
                "messageFeeTokenID": messageFeeTokenID,
                "result": INSUFFICIENT_ESCROW_BALANCE
            },
            topics=[ccm.sendingChainID, ccm.receivingChainID]
        )   
        raise Exception("Insufficient balance in the sending chain for the message fee.")

    # Transfer the fee from escrow to escrow. messageFeeTokenID is always getTokenIDLSK().
    escrowAmount(ccm.sendingChainID, messageFeeTokenID) -= ccm.fee
    escrowAmount(ccm.receivingChainID, messageFeeTokenID) += ccm.fee

    emitEvent(
        module=MODULE_NAME_TOKEN,
        name=EVENT_NAME_BEFORE_CCM_FORWARDING,
        data={
            "ccmID": Interoperability.encodeCCM(ccm),
            "messageFeeTokenID": messageFeeTokenID,
            "result": RESULT_SUCCESSFUL
        },
        topics=[ccm.sendingChainID, ccm.receivingChainID]
    )

Endpoints for Off-Chain Services

TBA

Backwards Compatibility

This introduces a different token handling mechanism for the whole Lisk ecosystem which requires a hard fork.

Reference Implementation

TBA