LIP: 0043
Title: Introduce chain registration mechanism
Author: Iker Alustiza <iker@lightcurve.io>
Discussions-To: https://research.lisk.com/t/chain-registration
Status: Active
Type: Standards Track
Created: 2021-05-22
Updated: 2024-01-04
Requires: 0038, 0045, 0049, 0056
This LIP introduces the concept of chain registration in the Lisk ecosystem. The chain registration is a necessary step to make a sidechain interoperable with the Lisk mainchain. Specifically, for the Lisk mainchain, this LIP specifies a new command for the sidechain registration. This command creates a sidechain account in the Lisk mainchain with some specific properties given by the user submitting the transaction. Similarly for sidechains, this LIP specifies the mainchain registration command.
This LIP is licensed under the Creative Commons Zero 1.0 Universal.
The Lisk ecosystem is a permissionless network of blockchains where a sidechain following the standard protocol can interoperate with any other Lisk compatible chain. In particular, the Lisk mainchain serves as a central node or relayer for the entire ecosystem and every cross-chain interaction has to be sent through it. This implies there should exist a standardized protocol for the Lisk mainchain to maintain a cross-chain channel to communicate with every sidechain.
The first step for establishing this cross-chain channel protocol is the chain registration process, which can be thought of as the creation/opening of the channel between two chains. This process defines the data structures, and protocol rules that every chain needs to implement in the ecosystem if they want to interoperate with another specific chain.
For a general picture of the Lisk interoperability architecture, please refer to LIP 0045.
In this LIP, the registration process introduced in the previous section is specified from the point of view of both the Lisk mainchain and sidechains. For the Lisk mainchain, this is done by the sidechain registration command, whereas for sidechains, it is done by the mainchain registration command.
As mentioned above, for a sidechain to be interoperable in the Lisk ecosystem, it has to be registered in the Lisk Mainchain via a sidechain registration command. A transaction with this command can be sent by any user account in the Lisk Mainchain with enough funds to pay the required fee. The processing of this command implies the creation of a sidechain account in the mainchain state associated with a unique chain identifier and a name. This means that every new sidechain occupies a certain namespace in the ecosystem. Additionally, every newly registered sidechain can increase the size of every mainchain cross-chain update command posted on sidechains (due to the increasing size of the outboxRootWitness
property of the command). For these two reasons, the minimum fee for this command has an added constant similar to the extra fee in a validator registration command. The value of this extra registration fee is CHAIN_REGISTRATION_FEE
LSK.
Once the sidechain registration command is processed, the sidechain account status is set to registered. In this state, the cross-chain channel is still not active, so the users on the mainchain or other chains cannot send cross-chain messages (CCMs) to this sidechain yet. Moreover, the liveness condition to maintain the channel is not enforced, this means that there is no specific time requirement for a sidechain to be activated on the mainchain, it can stay in the registered status for any period of time. When a first valid cross-chain update command from this sidechain is processed, the sidechain status is changed to active, making it active in the ecosystem. Now it is possible to send CCMs to the sidechain and the liveness condition is enforced.
When a new sidechain is registered on the mainchain via a sidechain registration command, new data structures are inserted for the sidechain in the Lisk mainchain state. Specifically, a new entry is created in five different substores of the interoperability module store (see Figure 1): outbox root substore, chain data substore, channel data substore, chain validators substore, and registered names. The values of these entries are initialized as specified in LIP 0045.
Figure 1: A summary of the Interoperability module store: Each box represents a substore, where we indicate the storeKey --> storeValue
relation. For the Lisk mainchain, the 'own chain' substore exists by default in the state whereas there is one entry per registered sidechain for five other substores (outbox root, chain data, channel data, chain validators, registered names) created by a sidechain registration command. For sidechains, the 'own chain' and one entry for the mainchain account for four other substores (outbox root, chain data, channel data, and chain validators) are created by the mainchain registration command.
The sidechain registration command contains the following parameters used to connect a new sidechain in the ecosystem.
The name
property sets the name of the sidechain as a string of characters. It has to be unique in the ecosystem and contain only characters from the set [a-z0-9!@$&_.].
The chain ID is a 4-byte constant set in the chain configuration. Chain IDs serve two purposes: (i) They are prepended to the input of the signing function of every transaction, block, or message of the chain to avoid transaction replays between different chains in the ecosystem;
(ii) they uniquely identify a chain in the Lisk ecosystem. In particular, in the Interoperability module, it serves a similar purpose for chains as addresses do for user accounts, as it is used to identify the chain account in the Interoperability module store. Furthermore, the chain ID has to be stated in every cross-chain interaction. For example, it has to be specified in the receivingChainID
property of a CCM to this sidechain and in the sendingChainId
property of a cross-chain update command from this sidechain.
The sidechain chain ID is given as a parameter in the sidechain registration command: If the given value is already taken by another sidechain, the sidechain registration command fails. In this case, the sidechain has to change the chain ID with a hardfork, and resubmit the sidechain registration command with a new value. The first byte should be set to the correct value depending on the network on which the chain is running.
The chain ID is known to the mainchain as soon as the sidechain is registered, thus it can validate cross-chain update commands coming from the sidechain without further context.
This property defines the set of eligible BLS public keys with their respective BFT weights required to sign the first certificate from the sidechain.
The sidechainCertificateThreshold
property is an integer setting the required cumulative weight needed for a certificate signature from the sidechain to be valid.
Once the sidechain has been registered on the mainchain, a similar registration process should happen in the sidechain before the interoperable channel is opened between the two chains. This is done by submitting a transaction with the mainchain registration command in the sidechain, which implies the creation of a mainchain account in the sidechain state associated with the Lisk mainchain and other structures needed for interoperability. This mainchain account has a similar structure as the one depicted in Figure 1.
This mainchain registration process has to happen always after the sidechain registration on the mainchain since the sidechain has no prior knowledge of its name and must be certain that the correct chain ID has been registered. Similar to the sidechain registration case, the mainchain account status will not change to active until a valid cross-chain update command from the mainchain containing a valid registration CCM is processed.
The mainchain registration command sets certain parameters in the sidechain related to the interoperability module and initializes the corresponding mainchain data structures. This command requires the approval of the sidechain validators. They have to agree on the content of this command and add their aggregated signatures accordingly. It is important that the sidechain validators make sure that they are signing the registration command with the right information from the mainchain, otherwise, the sidechain interoperable functionality may be unusable.
This command has no requirement for a minimum fee since it should be submitted only once in a sidechain and approved by a majority of validators. For this reason, a transaction with this command should be treated differently in terms of priority in case it is included in a sidechain nodeβs transaction pool. The recommendation is that, once the transaction is properly signed by the validators and ready to be submitted, a validator simply includes it in its next forged block without including it in the transaction pool. The command has the following parameters:
The chain ID set on the mainchain after processing the corresponding sidechain registration command.
The ownName
property sets the name of the sidechain in its own state according to the name given in the mainchain.
Similar to the sidechainValidators
property in the sidechain registration command, it defines the set of mainchain validators with their respective BFT weight expected to sign the first certificate from the mainchain.
Similar to the sidechainCertificateThreshold
property in the sidechain registration command, the mainchainCertificateThreshold
property is an integer setting the required cumulative weight needed for a certificate signature from the mainchain to be valid.
The signature
property is an aggregated signature of the sidechain validators. It ensures that the sidechain validators agree on registering the mainchain in the sidechain.
The aggregationBits
property is a bit vector used to validate the aggregated signature.
All interoperability constants are defined in LIP 0045.
Calling a function fct
from another module (named module
) is represented by module.fct(required inputs)
. Moreover, in this LIP, we use the function computeValidatorsHash
defined in LIP 0058 and the utility function getMainchainID
defined in LIP 0037.
Transactions executing this command have:
module = MODULE_NAME_INTEROPERABILITY
,command = COMMAND_REGISTER_SIDECHAIN
.
sidechainRegParams = {
"type": "object",
"required": [
"chainID",
"name",
"sidechainValidators",
"sidechainCertificateThreshold"
],
"properties": {
"chainID": {
"dataType": "bytes",
"length": CHAIN_ID_LENGTH,
"fieldNumber": 1
},
"name": {
"dataType": "string",
"minLength": MIN_CHAIN_NAME_LENGTH,
"maxLength": MAX_CHAIN_NAME_LENGTH,
"fieldNumber": 2
},
"sidechainValidators": {
"type": "array",
"minItems": 1,
"maxItems": MAX_NUM_VALIDATORS,
"fieldNumber": 3,
"items": { ...blsKeyAndBftWeightSchema } // Defined in LIP 0058
},
"sidechainCertificateThreshold": {
"dataType": "uint64",
"fieldNumber": 4
}
}
}
def verify(trs: Transaction) -> None:
trsParams = decode(sidechainRegParams, trs.params)
# The name property has to contain only characters from the set [a-z0-9!@$&_.].
if not re.match(r"^[a-z0-9!@$&_.]+$", trsParams.name):
raise Exception("Invalid name property. It should contain only characters from the set [a-z0-9!@$&_.].")
# The name property has to be unique with respect to the set of already registered sidechain names.
if there exists an entry in the registered names substore with store key equal to trsParams.name.encode("utf-8"):
raise Exception("Name already registered.")
# Chain ID has to be unique with respect to the set of already registered sidechains.
if there exists an entry in the chain data substore with store key equal to trsParams.chainID:
raise Exception("Chain ID already registered.")
# Check that the first byte of the chainID, indication the network, matches.
if trsParams.chainID[0] != getMainchainID()[0]:
raise Exception("Chain ID does not match the mainchain network.")
# Chain ID cannot be the mainchain chain ID.
if trsParams.chainID == getMainchainID():
raise Exception("Chain ID cannot be the mainchain chain ID.")
validatorKeys = [validator.blsKey for validator in trsParams.sidechainValidators]
# All validator keys must be distinct.
if len(validatorKeys) != len(set(validatorKeys)):
raise Exception("Duplicate BLS keys.")
# Validator keys must be in lexicographic order.
if not all(validatorKeys[i] < validatorKeys[i + 1] for i in range(len(validatorKeys) - 1)):
raise Exception("Validator keys are not in lexicographic order.")
totalWeight = 0
for validator in trsParams.sidechainValidators:
# The bftWeight property of each element is a positive integer.
if validator.bftWeight == 0:
raise Exception("Invalid bftWeight property.")
totalWeight += validator.bftWeight
# Total BFT weight has to be less than or equal to MAX_UINT64.
if totalWeight > MAX_UINT64:
raise Exception("Total BFT weight exceeds maximum value.")
# The range of valid values of the sidechainCertificateThreshold property is given by the total sum of the validators weights:
# Minimum value: floor(1/3 * total BFT weight) + 1.
# Maximum value = total BFT weight.
if trsParams.sidechainCertificateThreshold < totalWeight//3 + 1:
raise Exception("Certificate threshold is too small.")
if trsParams.sidechainCertificateThreshold > totalWeight:
raise Exception("Certificate threshold is too large.")
# Transaction fee has to be greater or equal than the registration fee.
if trs.fee < CHAIN_REGISTRATION_FEE:
raise Exception("Insufficient transaction fee.")
def execute(trs: Transaction) -> None:
trsParams = decode(sidechainRegParams, trs.params)
senderAddress = sha256(trs.senderPublicKey)[:ADDRESS_LENGTH]
# Create chain account.
sidechainAccount = {
"name": trsParams.name,
"lastCertificate": {
"height": 0,
"timestamp": 0,
"stateRoot": EMPTY_HASH,
"validatorsHash": computeValidatorsHash(trsParams.sidechainValidators, trsParams.sidechainCertificateThreshold)
},
"status": CHAIN_STATUS_REGISTERED
}
chainID = trsParams.chainID
create an entry in the chain data substore with
storeKey = chainID,
storeValue = encode(chainDataSchema, sidechainAccount)
# Create channel.
sidechainChannel = {
"inbox": {
"appendPath": [],
"size": 0,
"root": EMPTY_HASH
},
"outbox": {
"appendPath": [],
"size": 0,
"root": EMPTY_HASH
},
"partnerChainOutboxRoot": EMPTY_HASH,
"messageFeeTokenID": Token.getTokenIDLSK(),
"minReturnFeePerByte": MIN_RETURN_FEE_PER_BYTE_BEDDOWS
}
create an entry in the channel data substore with
storeKey = chainID
storeValue = encode(channelDataSchema, sidechainChannel)
# Create validators account.
sidechainValidators = {
"activeValidators": trsParams.sidechainValidators,
"certificateThreshold": trsParams.sidechainCertificateThreshold
}
create an entry in the chain validators data substore with
storeKey = chainID
storeValue = encode(chainValidatorsSchema, sidechainValidators)
# Create outbox root entry.
create an entry in the outbox root substore with
storeKey = chainID
storeValue = encode(outboxRootSchema, {"root": sidechainChannel.outbox.root})
# Create registered names entry.
create an entry in the registered names substore with
storeKey = trsParams.name.encode('ascii')
storeValue = encode(registeredNamesSchema, {"chainID": chainID})
# Pay the registration fee.
Fee.payFee(CHAIN_REGISTRATION_FEE)
# Initialize escrow account for token used for message fees.
Token.initializeEscrowAccount(chainID, sidechainChannel.messageFeeTokenID)
# Emit chain account updated event.
emitEvent(
module = MODULE_NAME_INTEROPERABILITY,
name = EVENT_NAME_CHAIN_ACCOUNT_UPDATED,
data = sidechainAccount,
topics = [chainID]
)
# Send registration CCM to the sidechain.
# We do not call sendInternal because it would fail as
# the receiving chain is not active yet.
registrationCCMParams = {
"name": trsParams.name,
"chainID": chainID,
"messageFeeTokenID": sidechainChannel.messageFeeTokenID,
"minReturnFeePerByte": MIN_RETURN_FEE_PER_BYTE_BEDDOWS
}
ccm = {
"nonce": ownChainAccount.nonce,
"module": MODULE_NAME_INTEROPERABILITY,
"crossChainCommand": CROSS_CHAIN_COMMAND_REGISTRATION,
"sendingChainID": ownChainAccount.chainID,
"receivingChainID": chainID,
"fee": 0,
"status": CCM_STATUS_CODE_OK,
"params": encode(registrationCCMParamsSchema, registrationCCMParams) # registrationCCMParamsSchema is defined in LIP0049
}
addToOutbox(chainID, ccm)
ownChainAccount.nonce += 1
# Emit CCM Sent Event.
ccmID = sha256(encode(crossChainMessageSchema, ccm))
emitEvent(
module = MODULE_NAME_INTEROPERABILITY,
name = EVENT_NAME_CCM_SENT_SUCCESS,
data = {"ccm": ccm},
topics = [ccm.sendingChainID, ccm.receivingChainID, ccmID]
)
Transactions executing this command have:
module = MODULE_NAME_INTEROPERABILITY
,command = COMMAND_REGISTER_MAINCHAIN
.
mainchainRegParams = {
"type": "object",
"required": [
"ownChainID",
"ownName",
"mainchainValidators",
"mainchainCertificateThreshold"
"signature",
"aggregationBits"
],
"properties": {
"ownChainID": {
"dataType": "bytes",
"length": CHAIN_ID_LENGTH,
"fieldNumber": 1
},
"ownName": {
"dataType": "string",
"minLength": MIN_CHAIN_NAME_LENGTH,
"maxLength": MAX_CHAIN_NAME_LENGTH,
"fieldNumber": 2
},
"mainchainValidators": {
"type": "array",
"minItems": 1,
"maxItems": NUM_ACTIVE_VALIDATORS_MAINCHAIN,
"fieldNumber": 3,
"items": { ...blsKeyAndBftWeightSchema } // Defined in LIP 0058
},
"mainchainCertificateThreshold": {
"dataType": "uint64",
"fieldNumber": 4
},
"signature": {
"dataType": "bytes",
"length": BLS_SIGNATURE_LENGTH,
"fieldNumber": 5
},
"aggregationBits": {
"dataType": "bytes",
"fieldNumber": 6
}
}
}
def verify(trs: Transaction) -> None:
trsParams = decode(mainchainRegParams, trs.params)
# The mainchain account must not exist already.
if chainAccount(getMainchainID()) exists:
raise Exception("Mainchain has already been registered.")
# The ownChainID property has to match with the chain identifier.
if trsParams.ownChainID != OWN_CHAIN_ID:
raise Exception("Invalid ownChainID property.")
# The ownName property has to contain only characters from the set [a-z0-9!@$&_.].
if not re.match(r"^[a-z0-9!@$&_.]+$", trsParams.ownName):
raise Exception("Invalid ownName property. It should contain only characters from the set [a-z0-9!@$&_.].")
validatorKeys = [validator.blsKey for validator in trsParams.mainchainValidators]
# All validator keys must be distinct.
if len(validatorKeys) != len(set(validatorKeys)):
raise Exception("Duplicate BLS keys.")
# Validator keys must be in lexicographic order.
if not all(validatorKeys[i] < validatorKeys[i + 1] for i in range(len(validatorKeys) - 1)):
raise Exception("Validator keys are not in lexicographic order.")
totalWeight = 0
for validator in trsParams.mainchainValidators:
# The bftWeight property of each element is a positive integer.
if validator.bftWeight == 0:
raise Exception("Invalid bftWeight property.")
totalWeight += validator.bftWeight
# Total BFT weight has to be less than or equal to MAX_UINT64.
if totalWeight > MAX_UINT64:
raise Exception("Total BFT weight exceeds maximum value.")
# The range of valid values of the mainchainCertificateThreshold property is given by the total sum of the validators weights:
# Minimum value: floor(1/3 * total BFT weight) + 1.
# Maximum value = total BFT weight.
if trsParams.mainchainCertificateThreshold < totalWeight//3 + 1:
raise Exception("Certificate threshold is too small.")
if trsParams.mainchainCertificateThreshold > totalWeight:
raise Exception("Certificate threshold is too large.")
def execute(trs: Transaction) -> None:
trsParams = decode(mainchainRegParams, trs.params)
# Check signature property.
sidechainValidators = sorted([(validator.blsKey, validator.bftWeight) for validator in Validators.getValidatorParams().validators], key = lambda v: v.blsKey)
blsKeys = [params[0] for params in sidechainValidators]
bftWeights = [params[1] for params in sidechainValidators]
certificateThreshold = Validators.getValidatorParams().certificateThreshold
registrationSignatureMessageSchema = {
"type": "object",
"required": ["ownChainID", "ownName", "mainchainValidators", "mainchainCertificateThreshold"],
"properties": {
"ownChainID": {
"dataType": "bytes",
"length": CHAIN_ID_LENGTH,
"fieldNumber": 1
},
"ownName": {
"dataType": "string",
"minLength": MIN_CHAIN_NAME_LENGTH,
"maxLength": MAX_CHAIN_NAME_LENGTH,
"fieldNumber": 2
},
"mainchainValidators": {
"type": "array",
"minItems": 1,
"maxItems": NUM_ACTIVE_VALIDATORS_MAINCHAIN,
"fieldNumber": 3,
"items": { ...blsKeyAndBftWeightSchema } // Defined in LIP 0058
},
"mainchainCertificateThreshold": {
"dataType": "uint64",
"fieldNumber": 4
}
}
}
message = encode(registrationSignatureMessageSchema,
{
"ownChainID": trsParams.ownChainID,
"ownName": trsParams.ownName,
"mainchainValidators": trsParams.mainchainValidators,
"mainchainCertificateThreshold": trsParams.mainchainCertificateThreshold
}
)
# verifyWeightedAggSig is specified in LIP 0062.
if (
verifyWeightedAggSig(
blsKeys,
trsParams.aggregationBits,
trsParams.signature,
MESSAGE_TAG_CHAIN_REG_MESSAGE,
OWN_CHAIN_ID,
bftWeights,
certificateThreshold,
message
)
== False
):
emitPersistentEvent(
module = MODULE_NAME_INTEROPERABILITY,
name = EVENT_NAME_INVALID_REGISTRATION_SIGNATURE,
data = {},
topics = [trsParams.ownChainID]
)
raise Exception("Invalid signature property.")
# Create chain account.
mainchainAccount = {
"name": CHAIN_NAME_MAINCHAIN,
"lastCertificate": {
"height": 0,
"timestamp": 0,
"stateRoot": EMPTY_HASH,
"validatorsHash": computeValidatorsHash(trsParams.mainchainValidators, trsParams.mainchainCertificateThreshold)
},
"status": CHAIN_STATUS_REGISTERED
}
create an entry in the chain data substore with
storeKey = getMainchainID()
storeValue = encode(chainDataSchema, mainchainAccount)
# Create channel.
mainchainChannel = {
"inbox": {
"appendPath": [],
"size": 0,
"root": EMPTY_HASH
},
"outbox": {
"appendPath": [],
"size": 0,
"root": EMPTY_HASH
},
"partnerChainOutboxRoot": EMPTY_HASH,
"messageFeeTokenID": Token.getTokenIDLSK(),
"minReturnFeePerByte": MIN_RETURN_FEE_PER_BYTE_BEDDOWS
}
create an entry in the channel data substore with
storeKey = getMainchainID()
storeValue = encode(channelDataSchema, mainchainChannel)
# Create validators account.
mainchainValidators = {
"activeValidators": trsParams.mainchainValidators,
"certificateThreshold": trsParams.mainchainCertificateThreshold
}
create an entry in the chain validators data substore with
storeKey = getMainchainID()
storeValue = encode(chainValidatorsSchema, mainchainValidators)
# Create outbox root entry.
create an entry in the outbox root substore with
storeKey = getMainchainID()
storeValue = encode(outboxRootSchema, {"root": mainchainChannel.outbox.root})
# Create own chain account.
ownChainAccount = {
"name": trsParams.ownName,
"chainID": trsParams.ownChainID,
"nonce": 0
}
create an entry in the own chain data substore with
storeKey = EMPTY_BYTES
storeValue = encode(ownChainAccountSchema, ownChainAccount)
# Emit chain account updated event.
emitEvent(
module = MODULE_NAME_INTEROPERABILITY,
name = EVENT_NAME_CHAIN_ACCOUNT_UPDATED,
data = mainchainAccount,
topics = [getMainchainID()]
)
# Send registration CCM to the mainchain.
# Notice that we do not use the send function because the channel
# has not been activated yet.
registrationCCMParams = {
"name": CHAIN_NAME_MAINCHAIN,
"chainID": getMainchainID(),
"messageFeeTokenID": mainchainChannel.messageFeeTokenID,
"minReturnFeePerByte": MIN_RETURN_FEE_PER_BYTE_BEDDOWS
}
ccm = {
"nonce": ownChainAccount.nonce,
"module": MODULE_NAME_INTEROPERABILITY,
"crossChainCommand": CROSS_CHAIN_COMMAND_REGISTRATION,
"sendingChainID": ownChainAccount.chainID,
"receivingChainID": getMainchainID(),
"fee": 0,
"status": CCM_STATUS_CODE_OK,
"params": encode(registrationCCMParamsSchema, registrationCCMParams) # registrationCCMParamsSchema is defined in LIP0049
}
addToOutbox(getMainchainID(), ccm)
ownChainAccount.nonce += 1
# Emit CCM Sent Event.
ccmID = sha256(encode(crossChainMessageSchema, ccm))
emitEvent(
module = MODULE_NAME_INTEROPERABILITY,
name = EVENT_NAME_CCM_SENT_SUCCESS,
data = {"ccm": ccm},
topics = [ccm.sendingChainID, ccm.receivingChainID, ccmID]
)
This proposal, together with LIP 0045, LIP 0053, LIP 0049, and LIP 0054, is part of the Interoperability module. Chains adding this module will need to do so with a hardfork.