Hello,
In this thread, I want to propose a LIP for the roadmap objective Introduce token standards
. The proposal’s contribution is to define a fungible token module to be used in the Lisk ecosystem.
I’m looking forward to your feedback.
Here is a complete LIP draft:
LIP:
Title: Define state and state transitions of Token module
Author: Maxime Gagnebin <maxime.gagnebin@lightcurve.io>
Type: Standards Track
Created: <YYYY-MM-DD>
Updated: <YYYY-MM-DD>
Discussions-To: https://research.lisk.com/t/define-state-and-state-transitions-of-token-module
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 chain ID of the chain minting the token and a local identifier, an integer chosen when the token is minted. 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 = 1
, it is also the first (and only) token of this chain and has localID = 0
. This entails that the LSK token ID is (1,0)
. Token identifiers in this LIP are written as a dictionary {"chainID": 1, "localID": 0}
(for example for the LSK token). Other document could also choose to represent them as a tuple, the LSK ID could then be written as (1,0)
.
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 functions below are the exposed methods of the Token module. For the Token module those functions are designed to allow a wide range of use cases while avoiding unexpected behaviors such as unwanted minting or unlocking of tokens.
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 native tokens. When burning tokens, this function will remove the specified amount of tokens from the user substore and at the same time decrease the total supply corresponding to the 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.
escrow
This function allows to transfer tokens from a user substore entry to an escrow substore entry. This should be done when native tokens are sent to another chain.
unescrow
This function allows to transfer tokens from an escrow substore entry to a user substore entry. This should be done when native tokens are returned from another chain.
transferEscrow
This function allows to transfer tokens from an escrow substore entry to another escrow substore entry. This is done when native tokens returning from another chain are directly sent to a third chain.
lock
This function is used to lock tokens held by a user. Locking tokens is done “module wise”, i.e., when locking tokens a moduleID
has to be specified. This allows locked tokens to be managed more securely. For example, if a token is locked in a DPoS 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 moduleID
, it is a protection allowing well written code to be more secure.
beforeSendCCM
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.
beforeExecuteCCM
This function is called by the Interoperability module before executing cross-chain messages. It handles crediting the message fee to the account of the cross-chain update sender. It should not be called by any other module.
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.
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 voting process should use the
lock
andunlock
function to lock and unlock voted tokens. - Block rewards should be assigned using the
mint
function. - The fee handling should use the
transfer
function to transfer the fee from the transaction sender to the block forger and, on the Lisk mainchain, theburn
function to burn the minimum part of the fee.
Specification
Constants and Notations
The following constants are used throughout the document
Name | Type | Value |
---|---|---|
Interoperability Constants | ||
MAINCHAIN_ID |
uint32 | 1 |
MIN_RETURN_FEE |
uint64 | 1000 |
Token Module Constants | ||
MODULE_ID_TOKEN |
uint32 | TBD |
COMMAND_ID_TRANSFER |
uint32 | 0 |
COMMAND_ID_CROSS_CHAIN_TRANSFER |
uint32 | 1 |
CROSS_CHAIN_COMMAND_ID_TRANSFER |
uint32 | 0 |
CROSS_CHAIN_COMMAND_ID_FORWARD |
uint32 | 1 |
CCM_STATUS_OK |
uint32 | 0 |
CCM_STATUS_TOKEN_NOT_SUPPORTED |
uint32 | 64 |
CCM_STATUS_PROTOCOL_VIOLATION |
uint32 | 65 |
CCM_STATUS_MIN_BALANCE_NOT_REACHED |
uint32 | 66 |
MIN_BALANCE |
uint64 | 50000000 |
CHAIN_ID_ALIAS_NATIVE |
uint32 | 0 |
LOCAL_ID_LSK |
uint32 | 0 |
TOKEN_ID_LSK |
object | {“chainID”: 1, “localID”: 0} |
TOKEN_ID_LSK_MAINCHAIN |
object | {“chainID”: 0, “localID”: 0} |
Token Store Constants | ||
STORE_PREFIX_USER |
bytes | 0x 00 00 |
STORE_PREFIX_SUPPLY |
bytes | 0x 80 00 |
STORE_PREFIX_ESCROW |
bytes | 0x c0 00 |
STORE_PREFIX_AVAILABLE_LOCAL_ID |
bytes | 0x d0 00 |
STORE_PREFIX_TERMINATED_ESCROW |
bytes | 0x e0 00 |
General Constants | ||
ADDRESS_LENGTH |
uint32 | 20 |
MAX_DATA_LENGTH |
uint32 | 64 |
uint32be
uint32be(x)
returns the big endian uint32 serialization of an integer x
, with 0 <= x < 2^32
. This serialization is always 4 bytes long.
Logic from Other Modules
Calling a function fct
implemented in the Interoperability module is represented by interoperability.fct(required inputs)
.
Token Identification
All tokens in the ecosystem are identified by a pair of non-negative integers (chainID, localID)
, both strictly less than 2^32. The first element of the pair, chainID
, is the chain ID of the chain that minted the token (an integer, as specified in the “Chain Registration” LIP) and the second element, localID
, is an integer specified when the token is minted.
In this LIP, the token identifier is written as a dictionary of 2 elements {"chainID": chainID, "localID": localID}
. This is for example used in all input formats for the module’s exposed functions. This choice follows a potential way the module could be implemented in JavaScript, the same behavior could be implemented with a named tuple in Python. This allows the exposed function interfaces to be simple and uniform.
Token ID and Native Tokens
Tokens on their native chain are identified by the pair {"chainID": CHAIN_ID_ALIAS_NATIVE, "localID": localID}
. The same tokens in other chains would be identified by the pair {"chainID": nativeChainID, "localID": localID}
.
In all sidechains, the LSK token is identified by the pair {"chainID": 1, "localID": 0}
, i.e., chainID = MAINCHAIN_ID = 1
and localID = 0
. This is in contrast with the LSK ID on mainchain which is {"chainID": 0, "localID": 0}
.
Supported Tokens
The Token module contains a function used when receiving cross-chain messages to assert the support for non-native tokens. It should return a boolean, depending on the configuration of the Token module. For the rest of this LIP, this function is written tokenSupported(tokenID)
. It must satisfy the condition below:
-
tokenSupported({"chainID": MAINCHAIN_ID, "localID": LOCAL_ID_LSK}) = True
. This corresponds to the token ID of the LSK token.
Further, on the Lisk mainchain, the LSK token is the only supported token (no tokens with different chain ID are supported).
Token Module Store
The Token module store is separated in four parts, the supply substore, the escrow substore, the terminated escrow substore and the user substore.
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 store prefix is set to
STORE_PREFIX_SUPPLY
. - Each store key is a serialized local ID:
uint32be(localID)
. - 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
.
Available Local ID Substore
The Token module store contains an entry dedicated to storing information about the available local IDs:
- The store prefix is set to
STORE_PREFIX_AVAILABLE_LOCAL_ID
. - Each store key is the empty bytes.
- Each store value is the serialization of an object following
availableLocalIDStoreSchema
.
availableLocalIDStoreSchema = {
"type": "object",
"properties": {
"nextAvailableLocalID": {
"dataType": "uint32",
"fieldNumber": 1
},
},
"required": ["nextAvailableLocalID"]
}
The default value for this substore is {"nextAvailableLocalID": 0}
serialized using availableLocalIDStoreSchema
.
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 store prefix is set to
STORE_PREFIX_ESCROW
. - Each store key is the identifier of the chain to which the tokens are escrowed, and the local ID of the escrowed token:
uint32be(escrowedChainID)||uint32be(tokenLocalID)
. - Each store value is the serialization of an object following
escrowStoreSchema
.
escrowStoreSchema = {
"type": "object",
"properties": {
"amount" : {
"dataType": "uint64",
"fieldNumber": 1
},
},
"required": ["amount"]
}
If any state transition would reduce the amount
property of an entry to zero, this entry is removed from the escrow substore.
If any state transition would increase the amount
property of a non-existent substore entry, this entry is created.
Terminated Escrow Substore
The Token module store contains an entry dedicated to storing information about chains which have violated the protocol described in this LIP. The state contains an entry with:
- The store prefix is set to
STORE_PREFIX_TERMINATED_ESCROW
. - Each store key is the identifier of a chain:
uint32be(chainID)
. - Each store value is the serialization of an object following
terminatedEscrowSchema
.
terminatedEscrowSchema = {
"type": "object",
"properties": {
"escrowTerminated": {
"dataType": "boolean",
"fieldNumber": 1
},
},
"required": ["escrowTerminated"]
}
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 store prefix is set to
STORE_PREFIX_USER
- Each store key is a 20-byte address, and a token ID:
address || uint32be(tokenID.chainID) || uint32be(tokenID.localID)
- 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":[ "moduleID", "amount" ],
"properties": {
"moduleID": {
"dataType":"uint32",
"fieldNumber": 1
},
"amount": {
"dataType": "uint64",
"fieldNumber": 2
}
}
}
}
}
}
In the above object, lockedBalances
is always kept ordered by ascending order of moduleID
. 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.
When, after any state transition, all amounts in a user substore entry (available and locked) are zero the state entry is removed.
If any state transition would increase the availableBalance
property of a non-existent store entry, this entry is created with default value and the available balance is set accordingly. The default value for this substore is {"availableBalance": 0, "lockedBalances": []}
serialized using userStoreSchema
.
Store Notation
For the rest of this proposal:
- Let
userStore(address, tokenID)
be the user substore entry with store keyaddress || uint32be(tokenID.chainID) || uint32be(tokenID.localID)
.- Let
availableBalance(address, tokenID)
be theavailableBalance
property ofuserStore(address, tokenID)
.
If the corresponding store entry does not exist, we assume that the available balance is 0. - Let
lockedAmount(address, moduleID, tokenID)
be the amount corresponding to the givenmoduleID
in thelockedBalances
array ofuserStore(address, tokenID)
.
If the corresponding store entry does not exist, we assume that the amount is 0.
- Let
- Let
escrowStore(chainID)
be the escrow substore entry with store keyuint32be(chainID)
.- Let
escrowAmount(chainID, localID)
be the amount corresponding to the givenlocalID
in theescrowedTokens
array ofescrowStore(chainID)
.
If the corresponding store entry does not exist, we assume that the amount is 0.
- Let
- Let
escrowTerminated(chainID)
be theescrowTerminated
property of the escrow terminated substore entry with store keyuint32be(chainID)
. If the store entry does not exist, we assume this notation to returnFalse
. - Let
supplyStore(localID)
be the supply substore entry with store keyuint32be(localID)
.- Let
totalSupply(localID)
be thetotalSupply
property stored insupplyStore(localID)
.-
- Let
- Let
nextAvailableLocalID
be thenextAvailableLocalID
property of the entry of the available local ID substore.
Store Function
In this proposal, the following function is used:
terminateEscrow(chainID):
create an store entry with
storePrefix = STORE_PREFIX_TERMINATED_ESCROW
storeKey = uint32be(chainID)
storeValue = {"escrowTerminated": True} serialized using terminatedEscrowSchema
In the above function, if the store entry already exists, the function has no effect.
Commands
The module provides the following commands to modify token entries.
Token Transfer
Transactions executing this command have:
moduleID = MODULE_ID_TOKEN
commandID = COMMAND_ID_TRANSFER
Parameters Schema
The params
property of token transfer transactions follows the schema transferParams
.
transferParams = {
"type": "object",
"required": [
"tokenID",
"amount",
"recipientAddress",
"data"
],
"properties": {
"tokenID": {
"type": "object",
"fieldNumber": 1,
"required": ["chainID", "localID"],
"properties": {
"chainID": {
"dataType": "uint32",
"fieldNumber": 1
},
"localID": {
"dataType": "uint32",
"fieldNumber": 2
}
}
},
"amount": {
"dataType": "uint64",
"fieldNumber": 2
},
"recipientAddress": {
"dataType": "bytes",
"fieldNumber": 3
},
"data": {
"dataType": "string",
"fieldNumber": 4
}
}
}
Parameters Validity
The params
property of a token transfer transaction is valid if:
-
recipientAddress
is a byte array of lengthADDRESS_LENGTH
. -
data
has length less than or equal toMAX_DATA_LENGTH
.
Execution
When executing a token transfer transaction trs
, the logic below is followed:
derive senderAddress from trs.senderPublicKey
let tokenID, recipientAddress, amount given by trs.params
if availableBalance(senderAddress, tokenID) < amount:
transaction execution fails
availableBalance(senderAddress, tokenID) -= amount
availableBalance(recipientAddress, tokenID) += amount
Cross-chain Token Transfer
Transactions executing this command have:
moduleID = MODULE_ID_TOKEN
commandID = COMMAND_ID_CROSS_CHAIN_TRANSFER
Parameters Schema
The params
property of cross-chain token transfer transactions follows the schema crossChainTransferParams
.
crossChainTransferParams = {
"type": "object",
"required": [
"tokenID",
"amount",
"receivingChainID",
"recipientAddress",
"data",
"messageFee"
],
"properties": {
"tokenID": {
"type": "object",
"fieldNumber": 1,
"required": ["chainID", "localID"],
"properties": {
"chainID": {
"dataType": "uint32",
"fieldNumber": 1
},
"localID": {
"dataType": "uint32",
"fieldNumber": 2
}
}
},
"amount": {
"dataType": "uint64",
"fieldNumber": 2
},
"receivingChainID": {
"dataType": "uint32",
"fieldNumber": 3
},
"recipientAddress": {
"dataType": "bytes",
"fieldNumber": 4
},
"data": {
"dataType": "string",
"fieldNumber": 5
},
"messageFee": {
"dataType": "uint64",
"fieldNumber": 6
}
}
}
Parameters Validity
The params
property of a cross-chain token transfer transaction is valid if
-
recipientAddress
is a byte array of lengthADDRESS_LENGTH
. -
data
has length less than or equal toMAX_DATA_LENGTH
. -
tokenID.chainID
is eitherCHAIN_ID_ALIAS_NATIVE
,MAINCHAIN_ID
orreceivingChainID
.
Execution
When executing a cross-chain token transfer transaction trs
, the following is done:
- Derive
senderAddress
fromtrs.senderPublicKey
. - Execute the logic defined by:
timestamp = timestamp of the block including the execution of this command transferCrossChain(timestamp, senderAddress, trs.params.receivingChainID, trs.params.recipientAddress, trs.params.tokenID, trs.params.amount, trs.params.messageFee, trs.params.data).
Cross-chain Commands
Cross-chain Token Transfer Messages
Cross-chain messages executing this cross-chain command have:
moduleID = MODULE_ID_TOKEN
crossChainCommandID = CROSS_CHAIN_COMMAND_ID_TRANSFER
CCM Parameters
The params
property of cross-chain token transfer messages follows the schema crossChainTransferMessageParams
.
crossChainTransferMessageParams = {
"type": "object",
"required": [
"tokenID",
"amount" ,
"senderAddress",
"recipientAddress",
"data"
],
"properties": {
"tokenID": {
"type": "object",
"fieldNumber": 1,
"required": ["chainID", "localID"],
"properties": {
"chainID": {
"dataType": "uint32",
"fieldNumber": 1
},
"localID": {
"dataType": "uint32",
"fieldNumber": 2
}
}
},
"amount": {
"dataType": "uint64",
"fieldNumber": 2
},
"senderAddress": {
"dataType": "bytes",
"fieldNumber": 3
},
"recipientAddress": {
"dataType": "bytes",
"fieldNumber": 4
},
"data": {
"dataType": "string",
"fieldNumber": 5
}
}
}
Execution
When executing a cross-chain token transfer message CCM
, the logic below is followed.
tokenID = CCM.params.tokenID
tokenChainID = tokenID.ChainID
tokenLocalID = tokenID.LocalID
amount = CCM.params.amount
recipientAddress = CCM.params.recipientAddress
senderAddress = CCM.params.senderAddress
sendingChainID = CCM.sendingChainID
ownChainID = interoperability.getOwnChainAccount().ID
# token should only be sent to and from their native chains
if (tokenChainID not in [ownChainID, sendingChainID]
or length(senderAddress) != ADDRESS_LENGTH
or length(recipientAddress) != ADDRESS_LENGTH
or length(CCM.params.data) > MAX_DATA_LENGTH
or (tokenChainID == ownChainID
and escrowAmount(sendingChainID, tokenLocalID) < amount)):
if (CCM.status == CCM_STATUS_OK
and CCM.fee >= MIN_RETURN_FEE * length(CCM)):
interoperability.error(CCM, CCM_STATUS_PROTOCOL_VIOLATION)
terminateEscrow(sendingChainID)
stop CCM execution
if tokenChainID == ownChainID:
escrowAmount(sendingChainID, tokenLocalID) -= amount
localTokenID = {"chainID": CHAIN_ID_ALIAS_NATIVE, "localID": tokenLocalID}
if CCM.status == 0:
availableBalance(recipientAddress, localTokenID) += amount
else:
availableBalance(senderAddress, localTokenID) += amount
else: # tokenChainID != ownChainID:
# return any non-supported tokens with enough fee
if tokenSupported(tokenID) == False:
if (CCM.fee >= MIN_RETURN_FEE*length(CCM)
and CCM.status == CCM_STATUS_OK):
interoperability.error(CCM, CCM_STATUS_TOKEN_NOT_SUPPORTED)
stop CCM execution
if CCM.status == CCM_STATUS_OK:
availableBalance(recipientAddress, tokenID) += amount
else:
availableBalance(senderAddress, tokenID) += amount
Cross-chain Token Forward Messages
Cross-chain messages executing this cross-chain command have:
moduleID = MODULE_ID_TOKEN
crossChainCommandID = CROSS_CHAIN_COMMAND_ID_FORWARD
CCM Parameters
The params
property of cross-chain token forward messages follows the schema crossChainForwardMessageParams
.
crossChainForwardMessageParams = {
"type": "object",
"required": [
"tokenID",
"amount" ,
"senderAddress",
"forwardToChainID",
"recipientAddress",
"data",
"forwardedMessageFee"
],
"properties": {
"tokenID": {
"type": "object",
"fieldNumber": 1,
"required": ["chainID", "localID"],
"properties": {
"chainID": {
"dataType": "uint32",
"fieldNumber": 1
},
"localID": {
"dataType": "uint32",
"fieldNumber": 2
}
}
},
"amount": {
"dataType": "uint64",
"fieldNumber": 2
},
"senderAddress": {
"dataType": "bytes",
"fieldNumber": 3
},
"forwardToChainID": {
"dataType": "bytes",
"fieldNumber": 4
},
"recipientAddress": {
"dataType": "bytes",
"fieldNumber": 5
},
"data": {
"dataType": "string",
"fieldNumber": 6
},
"forwardedMessageFee": {
"dataType": "uint64",
"fieldNumber": 7
}
}
}
Execution
When executing a cross-chain token forward message, the logic below is followed.
sendingChainID = CCM.sendingChainID
tokenID = CCM.params.tokenID
amount = CCM.params.amount
forwardToChainID = CCM.params.forwardToChainID
recipientAddress = CCM.params.recipientAddress
senderAddress = CCM.params.senderAddress
data = CCM.params.data
forwardedMessageFee = CCM.params.forwardedMessageFee
ownChainID = interoperability.getOwnChainAccount().ID
if (length(senderAddress) != ADDRESS_LENGTH
or length(recipientAddress) != ADDRESS_LENGTH
or length(CCM.params.data) > MAX_DATA_LENGTH):
if CCM.status == CCM_STATUS_OK:
interoperability.error(CCM, CCM_STATUS_PROTOCOL_VIOLATION)
terminateEscrow(sendingChainID)
stop CCM execution
if CCM.status != CCM_STATUS_OK:
if sendingChainID == tokenID.chainID:
# credit the sender with the returned tokens
availableBalance(senderAddress, tokenID) += amount + forwardedMessageFee
else:
# this should not happen, the sending chain modified the Token module
# the message is malicious and no tokens should be credited
terminateEscrow(sendingChainID)
stop CCM execution
if (tokenID.chainID != ownChainID
or escrowAmount(sendingChainID, tokenID.localID) < amount + forwardedMessageFee):
if CCM.status == CCM_STATUS_OK:
interoperability.error(CCM, CCM_STATUS_PROTOCOL_VIOLATION)
terminateEscrow(sendingChainID)
stop CCM execution
escrowAmount(sendingChainID, tokenID.localID) -= amount + forwardedMessageFee
localTokenID = {"chainID": CHAIN_ID_ALIAS_NATIVE, "localID": tokenID.localID}
availableBalance(senderAddress, localTokenID) += amount + forwardedMessageFee
messageParams: {
"tokenID": tokenID,
"amount": amount,
"senderAddress": senderAddress,
"recipientAddress": recipientAddress,
"data": data
}
serializedParams = serialization of messageParams
following crossChainTransferMessageParams
timestamp = timestamp of the block including the execution of this cross-chain command
interoperability.send(timestamp,
MODULE_ID_TOKEN,
CROSS_CHAIN_COMMAND_ID_TRANSFER,
forwardToChainID,
forwardedMessageFee,
senderAddress,
serializedParams)
if the above send function does not fail:
availableBalance(senderAddress, localTokenID) -= amount
# notice that the forwardedMessageFee was deducted by the send function
Tokens and Genesis Blocks
The genesis block of a chain can have a non-empty token store. The distribution of tokens at genesis is left to sidechain developers and must follow the conditions below:
- No entries with prefix key
STORE_PREFIX_ESCROW
should exist in the genesis block. - Only tokens with
chainID = CHAIN_ID_ALIAS_NATIVE
must exist in the genesis block. They can be part of the available balance or part of the locked balances. - For all
localID
, the sum of all corresponding amounts (available or locked) over all existing user store entries must equaltotalSupply(localID)
. -
nextAvailableLocalID > localID
for alllocalID
such thatsupply(localID)
exists.
Mainchain Minimum Balance Specifications
As specified in LIP 0025, mainchain user substore entries cannot hold less than MIN_BALANCE
of LSK token. To follow this rule:
- Executing transactions that would result in an address
address
withavailableBalance(address, TOKEN_ID_LSK_MAINCHAIN) < MIN_BALANCE
is invalid. This is checked as part of the “after transaction execution” logic of the block lifecycle. - Cross-chain messages that would result in an address
address
withavailableBalance(address, TOKEN_ID_LSK_MAINCHAIN) < MIN_BALANCE
after their execution must be rejected.
This is done by callinginteroperability.error(CCM, CCM_STATUS_MIN_BALANCE_NOT_REACHED)
on the rejected CCM.
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.
In the following, we use the function
canonicalTokenID(tokenID):
if tokenID.chainID == interoperability.getOwnChainAccount().ID
return {"chainID": CHAIN_ID_ALIAS_NATIVE, "localID": tokenID.localID}
else:
return tokenID
This allows the functions below to be called with the chain ID of native tokens being either CHAIN_ID_ALIAS_NATIVE
or the registered chain ID.
getAvailableBalance
getAvailableBalance(address, tokenID):
tokenID = canonicalTokenID(tokenID)
return availableBalance(address, tokenID)
getLockedAmount
getLockedAmount(address, moduleID, tokenID):
tokenID = canonicalTokenID(tokenID)
return lockedAmount(address, moduleID, tokenID)
getEscrowedAmount
getEscrowedAmount(escrowChainID, tokenID):
tokenID = canonicalTokenID(tokenID)
if tokenID.chainID != CHAIN_ID_ALIAS_NATIVE:
getEscrowedAmount fails
return escrowAmount(escrowChainID, tokenID.localID)
getEscrowStatus
getEscrowStatus(chainID):
return escrowTerminated(chainID)
getNextAvailableLocalID
getNextAvailableLocalID():
return nextAvailableLocalID
initializeToken
initializeToken(localID):
if supplyStore(localID) exists:
initializeToken fails
else:
create a supply substore entry with
storeKey = uint32be(localID)
storeValue = {"totalsupply": 0} serialized using escrowStoreSchema
if localID >= nextAvailableLocalID:
nextAvailableLocalID = localID + 1
return localID
mint
mint(address, tokenID, amount):
tokenID = canonicalTokenID(tokenID)
# this function is only used to mint native tokens
if (tokenID.chainID != CHAIN_ID_ALIAS_NATIVE
or amount < 0
or supplyStore(tokenID.localID) does not exist
or availableBalance(address, tokenID) + amount >= 2^64):
mint fails
availableBalance(address, tokenID) += amount
totalSupply(tokenID.localID) += amount
burn
burn(address, tokenID, amount):
tokenID = canonicalTokenID(tokenID)
if (tokenID.chainID != CHAIN_ID_ALIAS_NATIVE
or amount < 0
or availableBalance(address, tokenID) < amount):
burn fails
availableBalance(address, tokenID) -= amount
totalSupply(tokenID.localID) -= amount
transfer
transfer(senderAddress, recipientAddress, tokenID, amount):
tokenID = canonicalTokenID(tokenID)
if (amount < 0:
or availableBalance(senderAddress, tokenID) < amount):
transfer fails
availableBalance(senderAddress, tokenID) -= amount
availableBalance(recipientAddress, tokenID) += amount
transferCrossChain
transferCrossChain(timestamp,
senderAddress,
receivingChainID,
recipientAddress,
tokenID,
amount,
messageFee,
data):
tokenID = canonicalTokenID(tokenID)
chainID = tokenID.chainID
localID = tokenID.localID
if (amount < 0
or chainID not in [CHAIN_ID_ALIAS_NATIVE, MAINCHAIN_ID, receivingChainID]
or (escrowTerminated(receivingChainID) == True
and (chainID == CHAIN_ID_ALIAS_NATIVE or chainID == MAINCHAIN_ID))
or length(data) > MAX_DATA_LENGTH
or length(senderAddress) != ADDRESS_LENGTH
or length(recipientAddress) != ADDRESS_LENGTH
or availableBalance(senderAddress, tokenID) < amount):
transferCrossChain fails
if chainID == CHAIN_ID_ALIAS_NATIVE:
escrowAmount(receivingChainID, localID) += amount
newTokenID = {"chainID": interoperability.getOwnChainAccount().ID,
"localID": localID}
else:
newTokenID = tokenID
if chainID in [CHAIN_ID_ALIAS_NATIVE, receivingChainID]
availableBalance(senderAddress, tokenID) -= amount
messageParams: {
"tokenID": newTokenID,
"amount": amount,
"senderAddress": senderAddress,
"recipientAddress": recipientAddress,
"data": data
}
serializedParams = serialization of messageParams
following crossChainTransferMessageParams
interoperability.send(timestamp,
MODULE_ID_TOKEN,
CROSS_CHAIN_COMMAND_ID_TRANSFER,
receivingChainID,
messageFee,
senderAddress,
serializedParams)
else: # ie: chainID == MAINCHAIN_ID and receivingChainID != MAINCHAIN_ID
availableBalance(senderAddress, tokenID) -= amount + messageFee
messageParams: {
"tokenID": newTokenID,
"amount": amount,
"senderAddress": senderAddress,
"forwardToChainID": receivingChainID,
"recipientAddress": recipientAddress,
"data": data,
"forwardedMessageFee": messageFee
}
serializedParams = serialization of messageParams
following crossChainForwardMessageParams
interoperability.send(timestamp,
MODULE_ID_TOKEN,
CROSS_CHAIN_COMMAND_ID_FORWARD,
MAINCHAIN_ID,
0,
senderAddress,
serializedParams)
escrow
escrow(escrowChainID, address, tokenID, amount):
tokenID = canonicalTokenID(tokenID)
if (tokenID.chainID != CHAIN_ID_ALIAS_NATIVE
or amount < 0
or availableBalance(address, tokenID) < amount
or escrowTerminated(escrowChainID) == True):
escrow fails
availableBalance(address, tokenID) -= amount
escrowAmount(escrowChainID, tokenID.localID) += amount
unescrow
unescrow(escrowChainID, address, tokenID, amount):
tokenID = canonicalTokenID(tokenID)
if (tokenID.chainID != CHAIN_ID_ALIAS_NATIVE
or amount < 0
or escrowAmount(escrowChainID, localID) < amount):
unescrow fails
availableBalance(address, tokenID) += amount
escrowAmount(escrowChainID, tokenID.localID) -= amount
transferEscrow
transferEscrow(fromChainID, toChainID, tokenID, amount):
tokenID = canonicalTokenID(tokenID)
if (tokenID.chainID != CHAIN_ID_ALIAS_NATIVE
or amount < 0
or escrowAmount(fromChainID, tokenID.localID) < amount
or escrowTerminated(toChainID) == True):
transferEscrow fails
escrowAmount(fromChainID, tokenID.localID) -= amount
escrowAmount(toChainID, tokenID.localID) += amount
lock
lock(address, moduleID, tokenID, amount):
tokenID = canonicalTokenID(tokenID)
if (amount < 0
or availableBalance(address, tokenID) < amount):
lock fails
availableBalance(address, tokenID) -= amount
lockedAmount(address, moduleID, tokenID) += amount
unlock
unlock(address, moduleID, tokenID, amount):
tokenID = canonicalTokenID(tokenID)
if (amount < 0
or lockedAmount(address, moduleID, tokenID) < amount):
unlock fails
availableBalance(address, tokenID) += amount
lockedAmount(address, moduleID, tokenID) -= amount
beforeExecuteCCM
beforeExecuteCCM(relayerAddress, CCM):
fee = CCM.fee
if fee < 0:
beforeExecuteCCM fails
if interoperability.getOwnChainAccount().ID == MAINCHAIN_ID:
# if this chain is the mainchain, unescrow the fee
if escrowAmount(CCM.sendingChainID, LOCAL_ID_LSK) < fee:
beforeExecuteCCM fails
availableBalance(relayerAddress, TOKEN_ID_LSK_MAINCHAIN) += fee
escrowAmount(CCM.sendingChainID, LOCAL_ID_LSK) -= fee
else:
availableBalance(relayerAddress, TOKEN_ID_LSK) += fee
beforeSendCCM
beforeSendCCM(payFromAddress, CCM):
fee = CCM.fee
if fee < 0:
beforeSendCCM fails
if interoperability.getOwnChainAccount().ID == MAINCHAIN_ID:
# if this chain is the mainchain, escrow the fee
if availableBalance(payFromAddress, TOKEN_ID_LSK_MAINCHAIN) < fee:
beforeSendCCM fails
availableBalance(payFromAddress, TOKEN_ID_LSK_MAINCHAIN) -= fee
escrowAmount(CCM.receivingChainID, LOCAL_ID_LSK) += fee
else:
if availableBalance(payFromAddress, TOKEN_ID_LSK) < fee:
beforeSendCCM fails
availableBalance(payFromAddress, TOKEN_ID_LSK) -= fee
recover
recover(terminatedChainID, moduleID, storePrefix, storeKey, storeValue):
if (storePrefix != STORE_PREFIX_USER
or len(storeKey) != 28
or storeValue cannot be deserialized using userStoreSchema):
recover fails
address = first ADDRESS_LENGTH bytes of storeKey
chainID = bytes 21 to 24 of storeKey
localID = last 4 bytes of storeKey
account = storeValue deserialized using userStoreSchema
totalAmount = sum of availableBalance and all locked amounts of account
if (chainID != interoperability.getOwnChainAccount().ID
or escrowAmount(terminatedChainID, localID) < totalAmount):
recover fails
escrowAmount(terminatedChainID, localID) -= totalAmount
availableBalance(address, {"chainID":CHAIN_ID_ALIAS_NATIVE, "localID": localID}) += totalAmount
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