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
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.
This LIP is licensed under the Creative Commons Zero 1.0 Universal.
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.
- 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.
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
.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
This function creates an entry in the user substore.
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.
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.
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.
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
andunlock
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, thetransfer
function to transfer the remaining fee to the block forger and, on the Lisk mainchain, and theburn
function to burn the used part of the fee.
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.
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. |
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. |
Calling a function fct
implemented in the Interoperability module is represented by Interoperability.fct(required inputs)
.
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.
The Token module store is separated into several substores.
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.
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
.
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
}
}
}
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.
For the rest of this proposal:
- Let
userStore(address, tokenID)
be the user substore entry with store keyaddress + tokenID
, deserialized using theuserStoreSchema
schema.- Let
availableBalance(address, tokenID)
be theavailableBalance
property ofuserStore(address, tokenID)
. - Let
lockedAmount(address, module, tokenID)
be the amount corresponding to the givenmodule
in thelockedBalances
array ofuserStore(address, tokenID)
; if the array does not contain an entry for thismodule
value, we assume that the amount is 0.
- Let
- Let
escrowStore(chainID, tokenID)
be the escrow substore entry with store keychainID + tokenID
, deserialized using theescrowStoreSchema
schema.- Let
escrowAmount(chainID, tokenID)
be the amount value ofescrowStore(chainID, tokenID)
.
- Let
- Let
supplyStore(tokenID)
be the supply substore entry with store keytokenID
, deserialized using thesupplyStoreSchema
schema.- Let
totalSupply(tokenID)
be thetotalSupply
property stored insupplyStore(tokenID)
.
- Let
- Let
supportedTokens(chainID)
be the supported tokens substore entry with store keychainID
, deserialized using thesupportedTokensSchema
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)
whileuserStore(address, tokenID)
does not exist, this results to an error.
The module provides the following commands to modify token entries.
Transactions executing this command have:
module = MODULE_NAME_TOKEN
command = COMMAND_NAME_TRANSFER
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
}
}
}
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")
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.
Transactions executing this command have:
module = MODULE_NAME_TOKEN
command = COMMAND_NAME_CROSS_CHAIN_TRANSFER
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
}
}
}
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")
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 messages executing this cross-chain command have:
module = MODULE_NAME_TOKEN
crossChainCommand = CROSS_CHAIN_COMMAND_NAME_TRANSFER
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
}
}
}
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.")
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]
)
This event has name = EVENT_NAME_TRANSFER
. This event is emitted when the transfer function is called.
senderAddress
: the address of the account sending the transfer.recipientAddress
: the address of the account receiving the transfer.
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
}
}
}
This event has name = EVENT_NAME_TRANSFER_CROSS_CHAIN
.
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.
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
}
}
}
This event has name = EVENT_NAME_CCM_TRANSFER
. This event is emitted during the execution of cross-chain token transfer messages.
senderAddress
: the address of the account sending the transfer.recipientAddress
: the address of the account receiving the tokens.
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
}
}
}
This event has name = EVENT_NAME_MINT
. This event is emitted when the mint function is called.
address
: the address of the account to which to mint the tokens.
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
}
}
}
This event has name = EVENT_NAME_BURN
. This event is emitted when the burn function is called.
address
: the address of the account from which to burn the tokens.
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
}
}
}
This event has name = EVENT_NAME_LOCK
. This event is emitted when the lock function is called.
address
: the address of the account from which to lock the tokens.
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
}
}
}
This event has name = EVENT_NAME_UNLOCK
. This event is emitted when the unlock function is called.
address
: the address of the account from which to unlock the tokens.tokenID
: the token ID of the token to be unlocked.
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
}
}
}
This event has name = EVENT_NAME_INITIALIZE_TOKEN
. This event is emitted when the initialize token function is called.
tokenID
: the token ID to be initialized.
initializeTokenEventDataSchema = {
"type": "object",
"required" = ["tokenID", "result"],
"properties": {
"tokenID": {
"dataType": "bytes",
"length": TOKEN_ID_LENGTH,
"fieldNumber": 1
},
"result": {
"dataType": "uint32",
"fieldNumber": 2
}
}
}
This event has name = EVENT_NAME_INITIALIZE_USER_ACCOUNT
. This event is emitted when the initialized user store function is called.
address
: the address of the user store to be initialized.
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
}
}
}
This event has name = EVENT_NAME_INITIALIZE_ESCROW_ACCOUNT
. This event is emitted when the initialized user store function is called.
chainID
: the chain ID of the chain to be initialized in the escrow substore.
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
}
}
}
This event has name = EVENT_NAME_RECOVER
. This event is emitted when a state recovery for the token module is called.
address
: the address sending the recovery.
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
}
}
}
This event has name = EVENT_NAME_BEFORE_CCC_EXECUTION
. This event is emitted during calls of beforeCrossChainCommandExecution function.
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.
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
}
}
}
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.
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.
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
}
}
}
This event has name = EVENT_NAME_ALL_TOKENS_SUPPORTED
. This event is emitted when the supported tokens are updated to support all tokens.
allTokensSupportedDataSchema = {
"type": "object",
"required": [],
"properties": {}
}
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.
allTokensSupportedRemovedDataSchema = {
"type": "object",
"required": [],
"properties": {}
}
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.
chainID
: the ID of the chain for which all tokens are supported.
allTokensFromChainSupportedEventDataSchema = {
"type": "object",
"required" = ["chainID"],
"properties": {
"chainID": {
"dataType": "bytes",
"length": CHAIN_ID_LENGTH,
"fieldNumber": 1
}
}
}
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.
chainID
: the ID of the chain for which all tokens are supported.
Same as in previous event, i.e., follow the allTokensFromChainSupportedEventDataSchema
.
This event has name = EVENT_NAME_TOKEN_ID_SUPPORTED
. This event is emitted when a token ID is added to the supported tokens.
tokenID
: the token ID of the supported token.
tokenIDSupportedEventDataSchema = {
"type": "object",
"required" = ["tokenID"],
"properties": {
"legacyAddress": {
"dataType": "bytes",
"length": TOKEN_ID_LENGTH,
"fieldNumber": 1
}
}
}
This event has name = EVENT_NAME_TOKEN_ID_SUPPORT_REMOVED
. This event is emitted when a token ID is removed from the supported tokens.
tokenID
: the token ID for which the support is removed.
tokenIDSupportRemovedEventDataSchema = {
"type": "object",
"required" = ["tokenID"],
"properties": {
"tokenID": {
"dataType": "bytes",
"length": TOKEN_ID_LENGTH,
"fieldNumber": 1
}
}
}
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]
)
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]
)
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]
)
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
}
)
)
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.
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
def getChainID(tokenID: TokenID) -> ChainID:
return tokenID[:CHAIN_ID_LENGTH]
def isNativeToken(tokenID: TokenID) -> bool:
return getChainID(tokenID) == OWN_CHAIN_ID
def userSubstoreExists(address: Address, tokenID: TokenID) -> bool:
if userStore(address, tokenID) exists:
return True
else:
return False
def escrowSubstoreExists(chainID: ChainID, tokenID: TokenID) -> bool:
if escrowStore(chainID, tokenID) exists:
return True
else:
return False
def getAvailableBalance(address: Address, tokenID: TokenID) -> uint64:
if userSubstoreExists(address, tokenID):
return availableBalance(address, tokenID)
return 0
def getLockedAmount(address: Address, module: Module, tokenID: TokenID) -> uint64:
return lockedAmount(address, module, tokenID)
def getTotalSupply(tokenID: TokenID) -> uint64:
if totalSupply(tokenID) does not exist:
raise Exception("Total supply entry does not exist.")
return totalSupply(tokenID)
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)
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
def isTokenIDAvailable(tokenID: TokenID) -> bool:
if there exists an entry supplyStore(tokenID) in the supply substore:
return False
return True
def getTokenIDLSK() -> TokenID:
# The local ID of the LSK token is 0x00000000.
return getMainchainID() + b'\0'*4
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]
)
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]
)
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]
)
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)
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)
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]
)
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]
)
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]
)
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]
)
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
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]
)
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=[]
)
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=[]
)
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]
)
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]
)
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]
)
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]
)
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
}
}
}
}
}
}
}
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 pairsaddress, 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 ofaddress
. For a givenaddress
, the entries must be in lexicographic order oftokenID
. - For each
address, tokenID
pair:- The values used as
module
in thelockedBalances
array must be unique. - The
lockedBalances
array must be in lexicographic order ofmodule
name. - All elements of the
lockedBalances
array must haveamount != 0
.
- The values used as
- Across all element of the
supplySubstore
array, all values given fortokenID
must be unique. - The
supplySubstore
array must be in ascending order oftokenID
. - Across all elements of the
escrowSubstore
array, thetokenID
must be unique (it appears at most once). - The
escrowSubstore
array must be in ascending order oftokenID
. - The
supportedTokensSubstore
array must be in lexicographical order ofchainID
. For each entry of this array, thesupportedTokenIDs
array should be in lexicographic order.
- Across all elements of the
-
For each entry
userEntry
ingenesisBlockAssetObject.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
ingenesisBlockAssetObject.supplySubstore
, create an entry in the supply substore with:
storeKey = supplyEntry.tokenID
storeValue = encode(
schema=supplyStoreSchema,
object={"totalSupply": supplyEntry.totalSupply}
)
- For each entry
escrowEntry
ingenesisBlockAssetObject.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
ingenesisBlockAssetObject.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
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.
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.")
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
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]
)
TBA
This introduces a different token handling mechanism for the whole Lisk ecosystem which requires a hard fork.
TBA