Skip to content
This repository has been archived by the owner on Jun 11, 2024. It is now read-only.

Latest commit

Β 

History

History
2425 lines (1957 loc) Β· 77.7 KB

lip-0052.md

File metadata and controls

2425 lines (1957 loc) Β· 77.7 KB
LIP: 0052
Title: Introduce NFT module
Author: Maxime Gagnebin <[email protected]>
        Miroslav Jerkovic <[email protected]>
Discussions-To: https://research.lisk.com/t/introduce-nft-module/297
Status: Active (Lisk SDK only)
Type: Standards Track
Created: 2021-05-22
Updated: 2024-01-04
Requires: 0045

Abstract

The NFT (non-fungible token) module is used in the Lisk ecosystem for creating, destroying NFTs, and transferring them in the ecosystem. NFTs are uniquely identified assets. They can be transferred similarly to fungible tokens, but their unique identifiers can never be modified. In this module, NFTs also carry attributesArray that are used to store information specific to the NFT.

In this LIP, we specify the properties of the NFT module, along with their serialization and default values. Furthermore, we specify the state transitions logic defined within this module, i.e. the commands, the protocol logic injected during the block lifecycle, and the functions that can be called from other modules or off-chain services.

Copyright

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

Motivation

NFTs are very common in the blockchain space and have uses in a wide range of applications. This can go from being the virtual representation of a real world object (art, fashion, event tickets ...) to purely virtual collectibles (crypto kitties, ...).

Therefore, providing a unified module to handle, transfer and modify NFTs is a necessity for the Lisk ecosystem. The module presented here contains all the basic features that are needed to incorporate NFTs in a blockchain ecosystem without being restrictive on the way NFTs will be used by custom modules and applications.

Rationale

Technical Glossary

  • Native chain: with regards to an NFT, this is the chain where the NFT was created.
  • Native NFT: with regards to a chain, all NFTs created on this chain.
  • Foreign chain: with regards to an NFT, all chains other than the native chain.

NFT Module Store

Figure 1: The NFT module store is divided into four substores. All NFTs held by users are stored sequentially in the user substore with keys given by the user address and the NFT ID.

NFT Store

The NFT store contains entries for all NFTs present on the native chain, as well as entries for all native NFTs that have been sent cross-chain to a foreign chain. Each entry contains two properties:

  • The owner property can either be a LENGTH_ADDRESS bytes long user address or a LENGTH_CHAIN_ID bytes long chain ID. In the latter case, the NFT is a native NFT that has been sent cross-chain to a foreign chain and is escrowed.
  • The attributesArray property can be used by custom applications to store information about the NFT, or modify interactions with the NFT.

User Substore

In the proposed solution, all NFTs associated with a given address are stored sequentially in the user substore part of the state. In this way, getting all NFTs of a given account can be done efficiently. This is in contrast to specifications (like ERC 721 without optional extensions) where the NFT owner is only stored as one of the NFTs properties. We think that this feature is useful in an account-based blockchain ecosystem and the user substore is designed accordingly.

The lockingModule property stores the information regarding the locking status of the NFT. If the NFT is unlocked, this property will have the value NFT_NOT_LOCKED, whereas if the NFT is locked, this property will store the locking module name.

NFT Identifier

To identify NFTs in the Lisk ecosystem, we introduce the nftID, a unique NFT identifier in the ecosystem. It is a LENGTH_NFT_ID bytes long concatenation of the LENGTH_CHAIN_ID bytes long chainID, the chain ID of the chain creating the NFT, the LENGTH_COLLECTION_ID bytes long collectionID, chosen when the NFT is created, and a 8 bytes long serialization of an index integer, automatically assigned at the NFT creation.

This allows chains to define multiple sets of NFTs, each identified by their respective collection. Each collection can then easily have its own attributes schema and custom logic. For example, an art NFT exchange could have a different collection per artist, index being then a unique integer associated with each art piece of this artist.

Cross-chain NFT Transfer

To allow cross-chain transfers of NFTs, we define a specific command which makes use of the Interoperability module and creates a cross-chain message with the relevant information. When sending NFTs cross-chain, it is crucial that every native chain can correctly escrow its native NFTs sent to a foreign chain. In this way, a native NFT can never be created by a foreign chain and sent across the ecosystem. When receiving non-native NFTs on a chain, users can query this NFT's native chain to make sure that the NFT is properly escrowed.

Transfer To and From the Native Chain

These specifications only allow NFTs to be transferred from or to their native chain. In particular, this means that NFT created on chain A cannot be transferred directly from chain B to chain C. This is required to allow the native chain to maintain escrowed NFTs correctly.

Attributes

Each NFT is stored with an array of attributes specified by various modules, with each attribute property being a byte sequence that is not deserialized by the NFT module. Each custom module using NFTs should define schemas to serialize and deserialize their attributes property of NFTs.

When an NFT is sent to another chain, the attributes properties of the NFT can be modified according to specifications set on the receiving chain. When the NFT is received back on its native chain, the returned modified attributes are disregarded and the original attributes are restored, as currently defined by getNewAttributes function. If needed, custom modules can implement a more fine-grained approach towards the attributes that are modified cross-chain.

Note that the attributes properties are not limited in size by default, which can potentially cause the CCM validateFormat failure during the cross-chain NFT transfer.

Protocol Logic for Other Modules

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

create

This function is used to create a new NFT. The NFT will always be native to the chain creating it.

destroy

This function is used to destroy NFTs. The NFT will be removed from the NFT substore and cannot be retrieved, except in the case of destroying NFT on a foreign chain: the information about the NFT (e.g., the attributes) will still be available in the corresponding escrow entry of the NFT substore in the native chain.

lock

This function is used to lock an NFT to a module. A locked NFT cannot be transferred (within the chain or across chains). This can be useful, for example, when the NFT is used as a deposit for a service. Module is specified both when locking and unlocking the NFT, thus preventing NFTs being accidentally locked and unlocked by different modules.

unlock

This function is used to unlock an NFT that was locked to a module.

setAttributes

This function is used to modify the attributes of NFTs. Each custom module can define the rules surrounding modifying NFT attributes and should call this function. This function will be executed even if the NFT is locked.

transfer

This function is used to transfer ownership of NFTs within one chain.

transferCrossChain

This function is used to transfer ownership of NFTs across chains in the Lisk ecosystem.

recover

This function should only be called by the Interoperability module to trigger the recovery of NFTs escrowed to terminated chains.

Specification

Notation and Constants

The following constants are used throughout the document:

Name Type Value
Interoperability Constants
CCM_STATUS_CODE_OK uint32 0
MAX_RESERVED_ERROR_STATUS uint64 63
NFT Module Constants
MODULE_NAME_NFT string "nft"
COMMAND_NAME_TRANSFER string "transfer"
COMMAND_NAME_CROSS_CHAIN_TRANSFER string "transferCrossChain"
CROSS_CHAIN_COMMAND_NAME_TRANSFER string TBD
CCM_STATUS_NFT_NOT_SUPPORTED uint32 64
CCM_STATUS_PROTOCOL_VIOLATION uint32 65
NFT_NOT_LOCKED string MODULE_NAME_NFT
ALL_SUPPORTED_NFTS_KEY bytes EMPTY_BYTES
NFT Store Constants
SUBSTORE_PREFIX_NFT bytes 0x0000
SUBSTORE_PREFIX_USER bytes 0x8000
SUBSTORE_PREFIX_ESCROW bytes 0x4000
SUBSTORE_PREFIX_SUPPORTED_NFTS bytes 0xc000
Configurable Constants Mainchain Value
FEE_CREATE_NFT uint64 5000000
General Constants
OWN_CHAIN_ID bytes chainID of the chain.
LENGTH_ADDRESS uint32 20
MIN_LENGTH_MODULE_NAME uint32 1
MAX_LENGTH_MODULE_NAME uint32 32
LENGTH_NFT_ID uint32 16
LENGTH_CHAIN_ID uint32 4
LENGTH_COLLECTION_ID uint32 4
MAX_LENGTH_DATA uint32 64
EMPTY_BYTES bytes ""

Event Names and Results

Name Type Value Description
Names
EVENT_NAME_TRANSFER string "transfer" Name of the events emitted during NFT transfer.
EVENT_NAME_TRANSFER_CROSS_CHAIN string "transferCrossChain" Name of the events emitted during cross-chain NFT transfer.
EVENT_NAME_CCM_TRANSFER string "ccmTransfer" Name of the events emitted during execution of cross-chain NFT transfer messages.
EVENT_NAME_CREATE string "create" Name of the events emitted during calls to the create function.
EVENT_NAME_DESTROY string "destroy" Name of the events emitted during calls to the destroy function.
EVENT_NAME_LOCK string "lock" Name of the events emitted during calls to the lock function.
EVENT_NAME_UNLOCK string "unlock" Name of the events emitted during calls to the unlock function.
EVENT_NAME_SET_ATTRIBUTES string "setAttributes" Name of the events emitted during calls to the setAttributes function.
EVENT_NAME_RECOVER string "recover" Name of the events emitted during calls to the recover function.
EVENT_NAME_SUPPORT_ALL_NFTS string "supportAllNFTs" Name of the event emitted during calls to the supportAllNFTs function
EVENT_NAME_REMOVE_SUPPORT_ALL_NFTS string "removeSupportAllNFTs" Name of the event emitted during calls to the removeSupportAllNFTs function
EVENT_NAME_SUPPORT_ALL_NFTS_FROM_CHAIN string "supportAllNFTsFromChain" Name of the event emitted during calls to the supportAllNFTsFromChain function
EVENT_NAME_REMOVE_SUPPORT_ALL_NFTS_FROM_CHAIN string "removeSupportAllNFTsFromChain" Name of the event emitted during calls to the removeSupportAllNFTsFromChain function
EVENT_NAME_SUPPORT_ALL_NFTS_FROM_COLLECTION string "supportAllNFTsFromCollection" Name of the event emitted during calls to the supportAllNFTsFromCollection function
EVENT_NAME_REMOVE_SUPPORT_ALL_NFTS_FROM_COLLECTION string "removeSupportAllNFTsFromCollection" Name of the event emitted during calls to the removeSupportAllNFTsFromCollection function
Result codes
RESULT_SUCCESSFUL uint32 0 Successful result code for events.
RESULT_NFT_DOES_NOT_EXIST uint32 1 Used when NFT substore entry does not exist.
RESULT_NFT_NOT_NATIVE uint32 2 Used when NFT is not native to either the sending chain or the receiving chain.
RESULT_NFT_NOT_SUPPORTED uint32 3 Used when NFT is not supported in the receiving chain.
RESULT_NFT_LOCKED uint32 4 Used when destroy, lock or transfer functions fail due to NFT being locked.
RESULT_NFT_NOT_LOCKED uint32 5 Used when unlock function fails due to NFT being unlocked.
RESULT_UNAUTHORIZED_UNLOCK uint32 6 Used when NFT unlocking fails due to being requested by a module that did not lock it.
RESULT_NFT_ESCROWED uint32 7 Used when destroy, lock or transfer functions fail due to NFT being escrowed.
RESULT_NFT_NOT_ESCROWED uint32 8 Used when recover function fails due to NFT not being escrowed.
RESULT_INITIATED_BY_NONNATIVE_CHAIN uint32 9 Used when recover function fails due to not being initiated by the native chain.
RESULT_INITIATED_BY_NONOWNER uint32 10 Used when destroy or transfer functions fail due to not being initiated by the NFT owner.
RESULT_RECOVER_FAIL_INVALID_INPUTS uint32 11 Used when the recover function fails due to invalid inputs.
RESULT_INSUFFICIENT_BALANCE uint32 12 Used when the balance is not sufficient to pay for the cross-chain message fee.
RESULT_DATA_TOO_LONG uint32 13 Used when the data input is too long.
INVALID_RECEIVING_CHAIN uint32 14 Used when, during the cross-chain token transfer, the receiving chain is set to be equal to the sending chain.
RESULT_INVALID_ACCOUNT uint32 15 Used when recover function fails due to invalid account.

Type Definitions

Name Type Validation Description
Address bytes Must be of length LENGTH_ADDRESS. Address of an account.
Module string Must be of length at least MIN_LENGTH_MODULE_NAME and at most MAX_LENGTH_MODULE_NAME. Used for identifying modules.
NFTID bytes Must be of length LENGTH_NFT_ID. Used for NFT identifiers.
NFT dict Dictionary obtained by merging the NFT substore value for key nftID, deserialized using NFTStoreSchema, and the corresponding user substore value for key address + nftID, deserialized using userStoreSchema. NFT and user substores information for a provided nftID.
ChainID bytes Must be of length LENGTH_CHAIN_ID. Used for chain identifiers.
CollectionID bytes Must be of length LENGTH_COLLECTION_ID. Used for NFT collection identifiers.
AttributesArray (Module | bytes)[] Two-dimensional array consisting of Module names and corresponding attributes. Used to store information specific to the NFT.

uint64be Function

The function uint64be(x) returns the big endian uint64 serialization of an integer x, with 0 <= x < 2^64. This serialization is always 8 bytes long.

Logic from Other Modules

Calling a function fct from the Interoperability module is represented by Interoperability.fct(required inputs), from the Fee module by Fee.fct(required inputs), and from the Token module by Token.fct(required inputs).

NFT Identification

All NFTs in the ecosystem are identified by using the following three values:

  • chainID, always the chain ID of the chain that created the NFT,
  • collectionID, a LENGTH_COLLECTION_ID bytes long array, specified at NFT creation,
  • index, assigned at NFT creation to the next available index in the collection.

In this LIP, the NFT identifier nftID is a LENGTH_NFT_ID bytes long concatenation of the chainID of the NFT native chain, collectionID and the serialization of index: nftID = chainID + collectionID + uint64be(index). This is for example used in all input formats for the module's exposed logics. This allows the exposed logic interfaces to be simple and uniform.

Supported NFTs

The NFT module contains a function used when receiving cross-chain NFT transfers to assert the support for non-native NFTs. It should return a boolean, depending on the configuration of the NFT module. For the rest of this LIP, this function is written isNFTSupported(nftID).

NFT Module Store

The store keys and schemas for value serialization of the NFT module store are set as follows:

NFT Substore

  • The substore prefix is set to SUBSTORE_PREFIX_NFT.
  • Each store key is an NFT ID: nftID.
  • Each store value is the serialization of an object following NFTStoreSchema presented below.
  • Notation: For the rest of this proposal, let NFTStore[nftID] be the object value stored in the NFT substore with store key nftID, deserialized using NFTStoreSchema.

JSON Schema

NFTStoreSchema = {
    "type": "object",
    "required": [
        "owner",
        "attributesArray"
    ],
    "properties": {
        "owner": {
            "dataType": "bytes",
            "fieldNumber": 1
        },
        "attributesArray": {
            "type": "array",
            "fieldNumber": 2,
            "items": {
                "type": "object",
                "required": [
                    "module",
                    "attributes"
                ],
                "properties": {
                    "module": {
                        "dataType": "string",
                        "minLength": MIN_LENGTH_MODULE_NAME,
                        "maxLength": MAX_LENGTH_MODULE_NAME,
                        "pattern": "^[a-zA-Z0-9]*$",
                        "fieldNumber": 1
                    },
                    "attributes": {
                        "dataType": "bytes",
                        "fieldNumber": 2
                    }
                }
            }
        }
    }
}

Properties

  • owner: Either a LENGTH_ADDRESS bytes long NFT owner address or a LENGTH_CHAIN_ID bytes long chain ID in the case of a native NFT that has been escrowed.
  • attributesArray: An array containing attributes set by various modules. Elements include:
    • module: The name of the module that set the attributes.
    • attributes: The attributes set by the module.

Here, the attributesArray array is lexicographically ordered by module, which guarantees that serialization is consistent across nodes maintaining the chain.

If, for some module, a state transition deletes the attributes property , the corresponding entry in attributesArray is removed.

If, for some module, a state transition creates the attributes property of a non-existent store entry, this entry is created following NFTStoreSchema with the attributesArray entry set accordingly.

User Substore

  • The substore prefix is set to SUBSTORE_PREFIX_USER.
  • Each store key is a concatenation of an address and a NFT ID: address + nftID.
  • Each store value is the serialization of an object following userStoreSchema presented below.
  • Notation: For the rest of this proposal, let userStore[address, nftID] be the object value stored in the user substore with store key address + nftID, deserialized using userStoreSchema.

JSON Schema

userStoreSchema = {
    "type": "object",
    "required": ["lockingModule"],
    "properties": {
        "lockingModule": {
            "dataType": "string",
            "minLength": MIN_LENGTH_MODULE_NAME,
            "maxLength": MAX_LENGTH_MODULE_NAME,
            "pattern": "^[a-zA-Z0-9]*$",
            "fieldNumber": 1
        }
    }
}

Properties

  • lockingModule: The name of the module that locked the NFT. The default value for the lockingModule property is NFT_NOT_LOCKED.

Escrow Substore

  • The substore prefix is set to SUBSTORE_PREFIX_ESCROW.
  • Each store key is the identifier of the chain to which the NFTs are escrowed, and the NFT ID of the escrowed NFT: escrowedChainID + nftID.
  • Each store value follows the escrowStoreSchema schema presented below, which does not have any properties.
  • Notation: For the rest of this proposal, let escrowStore[escrowedChainID, nftID] be the EMPTY_BYTES value stored in the escrow substore with store key escrowedChainID + nftID.

JSON Schema

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

Supported NFTs Substore

  • The substore prefix is set to SUBSTORE_PREFIX_SUPPORTED_NFTS.
  • Each store key is the identifier of the chain to which the supported NFTs are native: chainID, or ALL_SUPPORTED_NFTS_KEY.
  • Each store value is the serialization of an object following supportedNFTsStoreSchema presented below.
  • Notation: For the rest of this proposal, let supportedNFTsStore[chainID] be the object value stored in the supported NFTs substore with store key chainID, deserialized using supportedNFTsStoreSchema.

JSON Schema

supportedNFTsStoreSchema = {
   "type": "object",
   "required": ["supportedCollectionIDArray"],
   "properties": {
       "supportedCollectionIDArray" : {
           "type": "array",
           "fieldNumber": 1,
           "items": {
                "type": "object",
                "required": ["collectionID"],
                "properties": {
                    "collectionID": {
                        "dataType": "bytes",
                        "length": LENGTH_COLLECTION_ID,
                        "fieldNumber": 1
                    }
                }
            }
       }
   }
}

Properties

  • supportedCollectionIDArray: The array of collectionID, specifying all the supported NFT collections of the foreign chain.

If all NFTs are supported, the substore contains an entry for the key ALL_SUPPORTED_NFTS_KEY and no other entries.

If not all NFTs are supported, but all NFTs from a chain with chainID are supported, the substore contains an entry for key chainID with an empty array as value.

Since the native NFTs are always supported, no entry with key OWN_CHAIN_ID is added to the substore.

For all entries in this substore, the entries of the supportedCollectionIDArray are ordered lexicographically.

Commands

The module provides the following commands to modify the NFT store.

NFT Transfer

Transactions executing this command have:

  • module = MODULE_NAME_NFT
  • command = COMMAND_NAME_TRANSFER
Parameters Schema

The params property of a NFT transfer transaction follows the schema NFTTransferParamsSchema.

NFTTransferParamsSchema = {
    "type": "object",
    "required": [
        "nftID",
        "recipientAddress",
        "data"
    ],
    "properties": {
        "nftID": {
            "dataType": "bytes",
            "length": LENGTH_NFT_ID,
            "fieldNumber": 1
        },
        "recipientAddress": {
            "dataType": "bytes",
            "length": LENGTH_ADDRESS,
            "fieldNumber": 2
        },
         "data": {
            "dataType": "string",
            "maxLength": MAX_LENGTH_DATA,
            "fieldNumber": 3
        }
    }
}
Verification
def verify(trs: Transaction) -> None:
    trsParams = decode(NFTTransferParamsSchema, trs.params)
    
    senderAddress = sha256(trs.senderPublicKey)[:LENGTH_ADDRESS]
    recipientAddress = trsParams.recipientAddress
    nftID = trsParams.nftID

    verifyTransferInternal(senderAddress, recipientAddress, nftID, False)
Execution
def execute(trs: Transaction) -> None:
    trsParams = decode(NFTTransferParamsSchema, trs.params)

    senderAddress = sha256(trs.senderPublicKey)[:LENGTH_ADDRESS]
    recipientAddress = trsParams.recipientAddress
    nftID = trsParams.nftID

    transferInternal(senderAddress, recipientAddress, nftID)

The transferInternal function transfers the ownership of the NFT within the chain.

Cross-chain NFT Transfer

Transactions executing this command have:

  • module = MODULE_NAME_NFT
  • command = COMMAND_NAME_CROSS_CHAIN_TRANSFER
Parameters Schema

The params property of a cross-chain NFT transfer transaction follows the crossChainNFTTransferParamsSchema schema.

crossChainNFTTransferParamsSchema = {
    "type": "object",
    "required": [
        "nftID",
        "receivingChainID",
        "recipientAddress",
        "data",
        "messageFee",
        "includeAttributes"
    ],
    "properties": {
        "nftID": {
            "dataType": "bytes",
            "length": LENGTH_NFT_ID,
            "fieldNumber": 1
        },
        "receivingChainID": {
            "dataType": "bytes",
            "length": LENGTH_CHAIN_ID,
            "fieldNumber": 2
        },
        "recipientAddress": {
            "dataType": "bytes",
            "length": LENGTH_ADDRESS,
            "fieldNumber": 3
        },
        "data": {
            "dataType": "string",
            "maxLength": MAX_LENGTH_DATA,
            "fieldNumber": 4
        },
        "messageFee": {
            "dataType": "uint64",
            "fieldNumber": 5
        },
        "includeAttributes": {
            "dataType": "boolean",
            "fieldNumber": 6
        }
    }
}
Verification
def verify(trs: Transaction) -> None:
    trsParams = decode(crossChainNFTTransferParamsSchema, trs.params)
    
    senderAddress = sha256(trs.senderPublicKey)[:LENGTH_ADDRESS]
    nftID = trsParams.nftID
    receivingChainID = trsParams.receivingChainID
    recipientAddress = trsParams.recipientAddress
    data = trsParams.data
    messageFee = trsParams.messageFee
    includeAttributes = trsParams.includeAttributes

    verifyTransferCrossChainInternal(
        senderAddress, 
        recipientAddress, 
        nftID, 
        receivingChainID, 
        messageFee, 
        data, 
        includeAttributes,
        False
    )
Execution
def execute(trs: Transaction) -> None:
    trsParams = decode(crossChainNFTTransferParamsSchema, trs.params)

    senderAddress = sha256(trs.senderPublicKey)[:LENGTH_ADDRESS]
    nftID = trsParams.nftID
    receivingChainID = trsParams.receivingChainID
    recipientAddress = trsParams.recipientAddress
    data = trsParams.data
    messageFee = trsParams.messageFee
    includeAttributes = trsParams.includeAttributes

    transferCrossChainInternal(
        senderAddress,
        recipientAddress,
        nftID,
        receivingChainID,
        messageFee,
        data,
        includeAttributes
    )

The transferCrossChainInternal function transfers ownership of NFTs across chains in the Lisk ecosystem and calls the interoperability module in order to create a CCM.

Cross-chain Commands

Cross-chain NFT Transfer Message

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

  • module = MODULE_NAME_NFT,
  • crossChainCommand = CROSS_CHAIN_COMMAND_NAME_TRANSFER
CCM Parameters Schema

The params property of a cross-chain NFT transfer message follows the crossChainNFTTransferMessageParamsSchema.

crossChainNFTTransferMessageParamsSchema = {
    "type": "object",
    "required": [
        "nftID",
        "senderAddress",
        "recipientAddress",
        "attributesArray",
        "data"
    ],
    "properties": {
        "nftID": {
            "dataType": "bytes",
            "length": LENGTH_NFT_ID,
            "fieldNumber": 1
        },
        "senderAddress": {
            "dataType": "bytes",
            "length": LENGTH_ADDRESS,
            "fieldNumber": 2
        },
        "recipientAddress": {
            "dataType": "bytes",
            "length": LENGTH_ADDRESS,
            "fieldNumber": 3
        },
        "attributesArray": {
            "type": "array",
            "fieldNumber": 4,
            "items": {
                "type": "object",
                "required": ["module", "attributes"],
                "properties": {
                    "module": {
                        "dataType": "string",
                        "minLength": MIN_LENGTH_MODULE_NAME,
                        "maxLength": MAX_LENGTH_MODULE_NAME,
                        "pattern": "^[a-zA-Z0-9]*$",
                        "fieldNumber": 1
                    },
                    "attributes": {
                        "dataType": "bytes",
                        "fieldNumber": 2
                    }
                }
            }
        },
        "data": {
            "dataType": "string",
            "maxLength": MAX_LENGTH_DATA,
            "fieldNumber": 5
        }
    }
}
Verification
def verify(
    trs: Transaction,
    ccm: CCM
) -> None:
    ccmParams = decode(crossChainNFTTransferMessageParamsSchema, ccm.params)
    
    nftID = ccmParams.nftID
    sendingChainID = ccm.sendingChainID

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

    if getChainID(nftID) not in [OWN_CHAIN_ID, sendingChainID]:
        raise Exception("NFT is not native to either the sending chain or the receiving chain")    

    if getChainID(nftID) == OWN_CHAIN_ID and NFTStore[nftID] entry does not exist:
        raise Exception("Non-existent entry in the NFT substore")

    if getChainID(nftID) == OWN_CHAIN_ID and NFTStore[nftID].owner != ccm.sendingChainID:
        raise Exception("NFT has not been properly escrowed")

    if getChainID(nftID) != OWN_CHAIN_ID and (ccm.status == CCM_STATUS_CODE_MODULE_NOT_SUPPORTED or ccm.status == CCM_STATUS_CODE_CROSS_CHAIN_COMMAND_NOT_SUPPORTED):
        raise Exception("Module or cross-chain command not supported")

    if getChainID(nftID) != OWN_CHAIN_ID and NFTStore[nftID] entry exists:
        raise Exception("NFT substore entry already exists")
Execution

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

def execute(
    trs: Transaction,
    ccm: CCM
) -> None:
    ccmParams = decode(crossChainNFTTransferMessageParamsSchema, ccm.params)
    
    nftID = ccmParams.nftID
    senderAddress = ccmParams.senderAddress
    recipientAddress = ccmParams.recipientAddress
    receivedAttributes = ccmParams.attributesArray
    data = ccmParams.data
    receivingChainID = ccm.receivingChainID
    sendingChainID = ccm.sendingChainID

    if getChainID(nftID) == OWN_CHAIN_ID: # Execution on the native chain
        storedAttributes = NFTStore[nftID].attributesArray
        if ccm.status == CCM_STATUS_CODE_OK:
            NFTStore[nftID].owner = recipientAddress
            NFTStore[nftID].attributesArray = getNewAttributes(nftID, storedAttributes, receivedAttributes)
            createUserEntry(recipientAddress, nftID)
            delete entry escrowEntry(sendingChainID, nftID) from the escrow substore
        else: # Return the NFT to the sender
            recipientAddress = senderAddress
            NFTStore[nftID].owner = recipientAddress
            createUserEntry(recipientAddress, nftID)
            delete entry escrowEntry(sendingChainID, nftID) from the escrow substore
    else: # Execution on the foreign chain
        if isNFTSupported(nftID) == False:
            emitPersistentEvent(
                module = MODULE_NAME_NFT,
                name = EVENT_NAME_CCM_TRANSFER,
                data = {
                    "senderAddress": senderAddress,
                    "recipientAddress": recipientAddress,
                    "nftID": nftID,
                    "sendingChainID": sendingChainID,
                    "receivingChainID": receivingChainID,
                    "result": RESULT_NFT_NOT_SUPPORTED
                },
                topics = [senderAddress, recipientAddress]
            )
            raise Exception("Non-supported NFT")
        if ccm.status == CCM_STATUS_CODE_OK:
            Fee.payFee(FEE_CREATE_NFT)
            createNFTEntry(recipientAddress, nftID, receivedAttributes)
            createUserEntry(recipientAddress, nftID)
        else: # Return the NFT to the sender
            Fee.payFee(FEE_CREATE_NFT)
            recipientAddress = senderAddress
            createNFTEntry(recipientAddress, nftID, receivedAttributes)
            createUserEntry(recipientAddress, nftID)

    emitEvent(
        module = MODULE_NAME_NFT,
        name = EVENT_NAME_CCM_TRANSFER,
        data = {
            "senderAddress": senderAddress,
            "recipientAddress": recipientAddress,
            "nftID": nftID,
            "sendingChainID": sendingChainID,
            "receivingChainID": receivingChainID,
            "result": RESULT_SUCCESSFUL
        },
        topics = [senderAddress, recipientAddress]
    )

Events

transfer

This event has name = EVENT_NAME_TRANSFER, and is emitted when the transfer and transferInternal functions are called.

Topics
  • senderAddress: The address of the sending account.
  • recipientAddress: The address of the receiving account.
Data
transferEventDataSchema = {
    "type": "object",
    "required": [
        "senderAddress",
        "recipientAddress",
        "nftID",
        "result"
    ],
    "properties": {
        "senderAddress": {
            "dataType": "bytes",
            "length": LENGTH_ADDRESS,
            "fieldNumber": 1
        },
        "recipientAddress": {
            "dataType": "bytes",
            "length": LENGTH_ADDRESS,
            "fieldNumber": 2
        },
        "nftID": {
            "dataType": "bytes",
            "length": LENGTH_NFT_ID,
            "fieldNumber": 3
        },
        "result": {
            "dataType": "uint32",
            "fieldNumber": 4
        }
    }
}

transferCrossChain

This event has name = EVENT_NAME_TRANSFER_CROSS_CHAIN, and is emitted when the transferCrossChain and transferCrossChainInternal functions are called.

Topics
  • senderAddress: The address of the sending account.
  • recipientAddress: The address of the receiving account.
  • receivingChainID: The chain ID of the receiving chain.
Data
transferCrossChainEventDataSchema = {
    "type": "object",
    "required": [
        "senderAddress",
        "recipientAddress",
        "nftID",
        "receivingChainID",
        "result"
    ],
    "properties": {
        "senderAddress": {
            "dataType": "bytes",
            "length": LENGTH_ADDRESS,
            "fieldNumber": 1
        },
        "recipientAddress": {
            "dataType": "bytes",
            "length": LENGTH_ADDRESS,
            "fieldNumber": 2
        },
        "nftID": {
            "dataType": "bytes",
            "length": LENGTH_NFT_ID,
            "fieldNumber": 3
        },
        "receivingChainID": {
            "dataType": "bytes",
            "length": LENGTH_CHAIN_ID,
            "fieldNumber": 4
        },
        "includeAttributes": {
            "dataType": "boolean",
            "fieldNumber": 5
        },
        "result": {
            "dataType": "bytes",
            "length": "uint32",
            "fieldNumber": 6
        }
    }
}

ccmTransfer

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

Topics
  • senderAddress: The address of the sending account.
  • recipientAddress: The address of the receiving account.
Data
ccmTransferEventDataSchema = {
    "type": "object",
    "required": [
        "senderAddress",
        "recipientAddress",
        "nftID",
        "sendingChainID",
        "receivingChainID",
        "result"
    ],
    "properties": {
        "senderAddress": {
            "dataType": "bytes",
            "length": LENGTH_ADDRESS,
            "fieldNumber": 1
        },
        "recipientAddress": {
            "dataType": "bytes",
            "length": LENGTH_ADDRESS,
            "fieldNumber": 2
        },
        "nftID": {
            "dataType": "bytes",
            "length": LENGTH_NFT_ID,
            "fieldNumber": 3
        },
        "sendingChainID": {
            "dataType": "bytes",
            "length": LENGTH_CHAIN_ID,
            "fieldNumber": 4
        },
        "receivingChainID": {
            "dataType": "bytes",
            "length": LENGTH_CHAIN_ID,
            "fieldNumber": 5
        },
        "result": {
            "dataType": "bytes",
            "length": "uint32",
            "fieldNumber": 6
        }
    }
}

create

This event has name = EVENT_NAME_CREATE, and is emitted when the create function is called.

Topics
  • address: The address of the NFT owner.
  • nftID: ID of the created NFT.
Data
createEventDataSchema = {
    "type": "object",
    "required": [
        "address",
        "nftID",
        "result"
    ],
    "properties": {
        "address": {
            "dataType": "bytes",
            "length": LENGTH_ADDRESS,
            "fieldNumber": 1
        },
        "nftID": {
            "dataType": "bytes",
            "length": LENGTH_NFT_ID,
            "fieldNumber": 2
        },
        "result": {
            "dataType": "uint32",
            "fieldNumber": 3
        }
    }
}

destroy

This event has name = EVENT_NAME_DESTROY, and is emitted when the destroy function is called.

Topics
  • address: The address of the NFT owner.
  • nftID: ID of the destroyed NFT.
Data
destroyEventDataSchema = {
    "type": "object",
    "required": [
        "address",
        "nftID",
        "result"
    ],
    "properties": {
        "address": {
            "dataType": "bytes",
            "length": LENGTH_ADDRESS,
            "fieldNumber": 1
        },
        "nftID": {
            "dataType": "bytes",
            "length": LENGTH_NFT_ID,
            "fieldNumber": 2
        },
        "result": {
            "dataType": "uint32",
            "fieldNumber": 3
        }
    }
}

lock

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

Topics
  • module: Name of the module that locked the NFT.
  • nftID: ID of the locked NFT.
Data
lockEventDataSchema = {
    "type": "object",
    "required": [
        "module",
        "nftID",
        "result"
    ],
    "properties": {
        "module": {
            "dataType": "string",
            "minLength": MIN_LENGTH_MODULE_NAME,
            "maxLength": MAX_LENGTH_MODULE_NAME,
            "fieldNumber": 1
        },
        "nftID": {
            "dataType": "bytes",
            "length": LENGTH_NFT_ID,
            "fieldNumber": 2
        },
        "result": {
            "dataType": "uint32",
            "fieldNumber": 3
        }
    }
}

unlock

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

Topics
  • module: Name of the module that unlocked the NFT.
  • nftID: ID of the unlocked NFT.
Data
unlockEventDataSchema = {
    "type": "object",
    "required": [
        "module",
        "nftID",
        "result"
    ],
    "properties": {
        "module": {
            "dataType": "string",
            "minLength": MIN_LENGTH_MODULE_NAME,
            "maxLength": MAX_LENGTH_MODULE_NAME,
            "fieldNumber": 1
        },
        "nftID": {
            "dataType": "bytes",
            "length": LENGTH_NFT_ID,
            "fieldNumber": 2
        },
        "result": {
            "dataType": "uint32",
            "fieldNumber": 3
        }
    }
}

setAttributes

This event has name = EVENT_NAME_SET_ATTRIBUTES, and is emitted when the setAttributes function is called.

Topics
  • nftID: ID of the NFT.
Data
setAttributesEventDataSchema = {
    "type": "object",
    "required": [
        "nftID",
        "attributes",
        "result"
    ],
    "properties": {
        "nftID": {
            "dataType": "bytes",
            "length": LENGTH_NFT_ID,
            "fieldNumber": 1
        },
        "attributes": {
            "dataType": "bytes",
            "fieldNumber": 2
        },
        "result": {
            "dataType": "uint32",
            "fieldNumber": 3
        }
    }
}

recover

This event has name = EVENT_NAME_RECOVER, and is emitted when the recover function is called.

Topics
  • nftID: ID of the recovered NFT.
Data
recoverEventDataSchema = {
    "type": "object",
    "required": [
        "terminatedChainID",
        "nftID",
        "result"
    ],
    "properties": {
        "terminatedChainID": {
            "dataType": "bytes",
            "maxLength": LENGTH_CHAIN_ID,
            "fieldNumber": 1
        },
        "nftID": {
            "dataType": "bytes",
            "length": LENGTH_NFT_ID,
            "fieldNumber": 2
        },
        "result": {
            "dataType": "uint32",
            "fieldNumber": 3
        }
    }
}

supportAllNFTs

This event has name = EVENT_NAME_SUPPORT_ALL_NFTS, and is emitted when the supportAllNFTs function is called.

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

removeSupportAllNFTs

This event has name = EVENT_NAME_REMOVE_SUPPORT_ALL_NFTS, and is emitted when the removeSupportAllNFTs function is called.

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

supportAllNFTsFromChain

This event has name = EVENT_NAME_SUPPORT_ALL_NFTS_FROM_CHAIN, and is emitted when the supportAllNFTsFromChain function is called.

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

removeSupportAllNFTsFromChain

This event has name = EVENT_NAME_REMOVE_SUPPORT_ALL_NFTS_FROM_CHAIN, and is emitted when the removeSupportAllNftsFromChain function is called.

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

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

supportAllNFTsFromCollection

This event has name = EVENT_NAME_SUPPORT_ALL_NFTS_FROM_COLLECTION, and is emitted when the supportAllNftsFromCollection function is called.

Topics
  • chainID: The ID of the native chain of the supported collection.
  • collectionID: The ID of the collection from which all NFTs are supported.
Data
supportAllNFTsFromCollectionEventDataSchema = {
    "type": "object",
    "required": [
        "chainID",
        "collectionID"
    ],
    "properties": {
        "chainID": {
            "dataType": "bytes",
            "length": LENGTH_CHAIN_ID,
            "fieldNumber": 1
        },
        "collectionID": {
            "dataType": "bytes",
            "length": LENGTH_COLLECTION_ID,
            "fieldNumber": 2
        }
    }
}

removeSupportAllNFTsFromCollection

This event has name = EVENT_NAME_REMOVE_SUPPORT_ALL_NFTS_FROM_COLLECTION, and is emitted when the removeSupportAllNFTsFromCollection function is called.

Topics
  • chainID: The ID of the native chain of the supported collection.
  • collectionID: The ID of the collection from which all NFTs are supported.
Data

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

Internal Functions

createNFTEntry

def createNFTEntry(
    address: Address,
    nftID: NFTID,
    attributesArray: AttributesArray
) -> None:

    if len(attributesArray) != len(set(item.module for item in attributesArray)):
        raise Exception("Invalid attributes array provided")

    create substore entry with
        substorePrefix = SUBSTORE_PREFIX_NFT
        key = nftID
        value = encode(
            schema = NFTStoreSchema,
            object = {
               "owner": address,
               "attributesArray": attributesArray
            }
        )

createUserEntry

def createUserEntry(
    address: Address,
    nftID: NFTID
) -> None:

    create substore entry with
        substorePrefix = SUBSTORE_PREFIX_USER
        key = address + nftID
        value = encode(
            schema = userStoreSchema,
            object = {
               "lockingModule": NFT_NOT_LOCKED
            }
        )

createEscrowEntry

def createEscrowEntry(
    receivingChainID: ChainID,
    nftID: NFTID
) -> None:

    create substore entry with
        substorePrefix = SUBSTORE_PREFIX_ESCROW
        key = receivingChainID + nftID

verifyTransferInternal

def verifyTransferInternal(
    senderAddress: Address,
    recipientAddress: Address,
    nftID: NFTID,
    emitEvent: bool
) -> None:

    try:
        nft = getNFT(nftID)
    except:
        if emitEvent:
            emitFailedTransferEvent(senderAddress, recipientAddress, nftID, RESULT_NFT_DOES_NOT_EXIST)
        raise Exception("NFT does not exist")

    owner = nft["owner"]

    if isNFTEscrowed(nft):
        if emitEvent:
            emitFailedTransferEvent(senderAddress, recipientAddress, nftID, RESULT_NFT_ESCROWED)
        raise Exception("NFT is escrowed to another chain")

    if owner != senderAddress:
        if emitEvent:
            emitFailedTransferEvent(senderAddress, recipientAddress, nftID, RESULT_INITIATED_BY_NONOWNER)
        raise Exception("Transfer not initiated by the NFT owner")

    if isNFTLocked(nft):
        if emitEvent:
            emitFailedTransferEvent(senderAddress, recipientAddress, nftID, RESULT_NFT_LOCKED)
        raise Exception("Locked NFTs cannot be transferred")
  
def emitFailedTransferEvent(
    senderAddress: Address,
    recipientAddress: Address,
    nftID: NFTID,
    result: uint32
) -> None:

    emitPersistentEvent(
        module = MODULE_NAME_NFT,
        name = EVENT_NAME_TRANSFER,
        data = {
            "senderAddress": senderAddress,
            "recipientAddress": recipientAddress,
            "nftID": nftID,
            "result": result
        },
        topics = [senderAddress, recipientAddress]
    )    

transferInternal

def transferInternal(
    senderAddress: Address,
    recipientAddress: Address,
    nftID: NFTID
) -> None:

    delete entry userStore[senderAddress, nftID] from the user substore
    createUserEntry(recipientAddress, nftID)
    NFTStore[nftID].owner = recipientAddress

    emitEvent(
        module = MODULE_NAME_NFT,
        name = EVENT_NAME_TRANSFER,
        data = {
            "senderAddress": senderAddress,
            "recipientAddress": recipientAddress,
            "nftID": nftID,
            "result": RESULT_SUCCESSFUL
        },
        topics = [senderAddress, recipientAddress]
    )

verifyTransferCrossChainInternal

def verifyTransferCrossChainInternal(
    senderAddress: Address,
    recipientAddress: Address,
    nftID: NFTID,
    receivingChainID: ChainID,
    messageFee: uint64,
    data: str,
    includeAttributes: bool,
    emitEvent: bool
) -> None:

    try:
        nft = getNFT(nftID)
    except:
        if emitEvent:
            emitFailedTransferCrossChainEvent(senderAddress, recipientAddress, nftID, receivingChainID, RESULT_NFT_DOES_NOT_EXIST)
        raise Exception("NFT does not exist")
    
    owner = nft["owner"]

    if getChainID(nftID) not in [OWN_CHAIN_ID, receivingChainID]:
        if emitEvent:
            emitFailedTransferCrossChainEvent(senderAddress, recipientAddress, nftID, receivingChainID, RESULT_NFT_NOT_NATIVE)
        raise Exception("NFT must be native either to the sending chain or the receiving chain")

    if receivingChainID == OWN_CHAIN_ID:
        if emitEvent:
            emitFailedTransferCrossChainEvent(senderAddress, recipientAddress, nftID, receivingChainID, INVALID_RECEIVING_CHAIN)
        raise Exception("Receiving chain cannot be the sending chain.")

    if len(data) > MAX_LENGTH_DATA:
        if emitEvent:
            emitFailedTransferCrossChainEvent(senderAddress, recipientAddress, nftID, receivingChainID, RESULT_DATA_TOO_LONG)
        raise Exception("Data field is too long")    

    if isNFTEscrowed(nft):
        if emitEvent:
            emitFailedTransferCrossChainEvent(senderAddress, recipientAddress, nftID, receivingChainID, RESULT_NFT_ESCROWED)
        raise Exception("NFT is escrowed to another chain")

    if owner != senderAddress:
        if emitEvent:
            emitFailedTransferCrossChainEvent(senderAddress, recipientAddress, nftID, receivingChainID, RESULT_INITIATED_BY_NONOWNER)
        raise Exception("Transfer not initiated by the NFT owner")

    if isNFTLocked(nft):
        if emitEvent:
            emitFailedTransferCrossChainEvent(senderAddress, recipientAddress, nftID, receivingChainID, RESULT_NFT_LOCKED)
        raise Exception("Locked NFTs cannot be transferred")

    messageFeeTokenID = Interoperability.getMessageFeeTokenID(receivingChainID)

    if Token.getAvailableBalance(senderAddress, messageFeeTokenID) < messageFee:
        if emitEvent:
            emitFailedTransferCrossChainEvent(senderAddress, recipientAddress, nftID, receivingChainID, RESULT_INSUFFICIENT_BALANCE)
        raise Exception("Insufficient balance for the message fee")  

def emitFailedTransferCrossChainEvent(
    senderAddress: Address,
    recipientAddress: Address,
    nftID: NFTID,
    receivingChainID: ChainID,
    result: uint32
) -> None:

    emitPersistentEvent(
        module = MODULE_NAME_NFT,
        name = EVENT_NAME_TRANSFER_CROSS_CHAIN,
        data = {
            "senderAddress": senderAddress,
            "recipientAddress": recipientAddress,
            "nftID": nftID,
            "receivingChainID": receivingChainID,
            "includeAttributes": includeAttributes,
            "result": result
        },
        topics = [senderAddress, recipientAddress, receivingChainID]
    )

transferCrossChainInternal

def transferCrossChainInternal(
    senderAddress: Address,
    recipientAddress: Address,
    nftID: NFTID,
    receivingChainID: ChainID,
    messageFee: uint64,
    data: str,
    includeAttributes: bool
) -> None:

    if getChainID(nftID) == OWN_CHAIN_ID:
        NFTStore[nftID].owner = receivingChainID
        delete entry userStore[senderAddress, nftID] from the user substore
        if escrowStore[receivingChainID, nftID] does not exist:
            createEscrowEntry(receivingChainID, nftID)

    if getChainID(nftID) == receivingChainID:
        destroy(senderAddress, nftID)

    if includeAttributes:
        attributesArray = NFT[nftID].attributesArray
    else:
        attributesArray = []

    emitEvent(
        module = MODULE_NAME_NFT,
        name = EVENT_NAME_TRANSFER_CROSS_CHAIN,
        data = {
            "senderAddress": senderAddress,
            "recipientAddress": recipientAddress,
            "nftID": nftID,
            "receivingChainID": receivingChainID,
            "includeAttributes": includeAttributes,
            "result": RESULT_SUCCESSFUL
        },
        topics = [senderAddress, recipientAddress, receivingChainID]
    )

    Interoperability.send(
        sendingAddress = senderAddress,
        module = MODULE_NAME_NFT,
        crossChainCommand = CROSS_CHAIN_COMMAND_NAME_TRANSFER,
        receivingChainID = receivingChainID,
        fee = messageFee,
        params = encode(
            schema = crossChainNFTTransferMessageParamsSchema,
            object = {
                "nftID": nftID,
                "senderAddress": senderAddress,
                "recipientAddress": recipientAddress,
                "attributesArray": attributesArray,
                "data": data
            }
        )
    )

getNewAttributes

This function is used when the native NFT is received from a foreign chain, with a default behavior is to always rewrite the received attributes with the ones in the NFT substore.

def getNewAttributes(
    nftID: NFTID,
    storedAttributes: AttributesArray,
    receivedAttributes: AttributesArray
) -> AttributesArray:
    return storedAttributes

Protocol Logic for Other Modules

isNFTEscrowed

This function returns the escrow status of an NFT.

def isNFTEscrowed(nft: NFT) -> bool:
    owner = nft["owner"]
    return len(owner) != LENGTH_ADDRESS

isNFTLocked

This function returns the locking status of an NFT.

def isNFTLocked(nft: NFT) -> bool:
    if "lockingModule" not in nft:
        return False
    lockingModule = nft["lockingModule"]
    return lockingModule != NFT_NOT_LOCKED 

isNFTSupported

This function returns the support status of an NFT.

def isNFTSupported(nftID: NFTID) -> bool:

    chainID = getChainID(nftID)
    collectionID = getCollectionID(nftID)

    if getChainID(nftID) == OWN_CHAIN_ID:
        return True

    if supportedNFTsStore[ALL_SUPPORTED_NFTS_KEY] exists:
        return True

    if supportedNFTsStore[chainID] exists:
        if supportedNFTsStore[chainID].supportedCollectionIDArray == []:
            return True
        if collectionID is in supportedNFTsStore[chainID].supportedCollectionIDArray:
            return True

    return False

getChainID

This function returns the native chain chainID of an NFT.

def getChainID(nftID: NFTID) -> ChainID:
    return nftID[:LENGTH_CHAIN_ID]

getCollectionID

This function returns the collectionID of an NFT.

def getCollectionID(nftID: NFTID) -> CollectionID:
    return nftID[LENGTH_CHAIN_ID:(LENGTH_CHAIN_ID + LENGTH_COLLECTION_ID)]

getNFT

This function returns NFT and user substores information for a provided nftID.

def getNFT(nftID: NFTID) -> dict:
    if NFTStore[nftID] does not exist:
        raise Exception("NFT substore entry does not exist")
    owner = NFTStore[nftID].owner    
    attributesArray = [
            {
                "module": attribute.module,
                "attributes": attribute.attributes
            }
            for attribute in NFTStore[nftID].attributesArray
    ]
    nftIDInfo = {
        "owner": owner,
        "attributesArray": attributesArray
    }
    if len(owner) == LENGTH_ADDRESS:
        if userStore[owner, nftID] does not exist:
            raise Exception("User substore entry does not exist")
        lockingModule = userStore[owner, nftID].lockingModule 
        nftIDInfo["lockingModule"] = lockingModule
    return nftIDInfo

getNextAvailableIndex

This function returns the next available index of a collection.

def getNextAvailableIndex(collectionID: CollectionID) -> uint64:
    start = OWN_CHAIN_ID + collectionID + uint64be(0)
    end = OWN_CHAIN_ID + collectionID + uint64be(2**64 - 1) 
    largestKey = lexicographically largest element in NFTStore with key between start and end
    if largestKey does not exist:
        return 0
    largestIndex = int.from_bytes(largestKey[(LENGTH_CHAIN_ID + LENGTH_COLLECTION_ID):], byteorder='big')
    if largestIndex == 2**64 - 1:
        raise Exception("No more available indexes")
    return largestIndex + 1

create

This function creates an NFT.

def create(
    address: Address,
    collectionID: CollectionID,
    attributesArray: AttributesArray
) -> None:

    index = getNextAvailableIndex(collectionID)
    nftID = OWN_CHAIN_ID + collectionID + uint64be(index)

    if len(attributesArray) != len(set(item.module for item in attributesArray)):
        raise Exception("Invalid attributes array provided")

    Fee.payFee(FEE_CREATE_NFT)
    createNFTEntry(address, nftID, attributesArray)
    createUserEntry(address, nftID)

    emitEvent(
        module = MODULE_NAME_NFT,
        name = EVENT_NAME_CREATE,
        data = {
            "address": address,
            "nftID": nftID,
            "result": RESULT_SUCCESSFUL
        },
        topics = [address, nftID]
    )

destroy

This function destroys an NFT.

def destroy(
    address: Address,
    nftID: NFTID
) -> None:

    try:
        nft = getNFT(nftID)
    except:
        emitFailedDestroyEvent(address, nftID, RESULT_NFT_DOES_NOT_EXIST)
        raise Exception("NFT does not exist")

    owner = nft["owner"]

    if isNFTEscrowed(nft):
        emitFailedDestroyEvent(address, nftID, RESULT_NFT_ESCROWED)
        raise Exception("NFT is escrowed to another chain")

    if owner != address:
        emitFailedDestroyEvent(address, nftID, RESULT_INITIATED_BY_NONOWNER)
        raise Exception("Not initiated by the NFT owner")

    if isNFTLocked(nft):
        emitFailedDestroyEvent(address, nftID, RESULT_NFT_LOCKED)
        raise Exception("Locked NFTs cannot be destroyed")

    delete entry NFTStore[nftID] from the NFT substore
    delete entry userStore[address, nftID] from the user substore

    emitEvent(
        module = MODULE_NAME_NFT,
        name = EVENT_NAME_DESTROY,
        data = {
            "address": address,
            "nftID": nftID,
            "result": RESULT_SUCCESSFUL
        },
        topics = [address, nftID]
    )

def emitFailedDestroyEvent(
    address: Address,
    nftID: NFTID,
    result: uint32
) -> None:

    emitPersistentEvent(
        module = MODULE_NAME_NFT,
        name = EVENT_NAME_DESTROY,
        data = {
            "address": address,
            "nftID": nftID,
            "result": result
        },
        topics = [address, nftID]
    )

lock

This function locks an NFT to a given module. Note that an NFT can not be locked to the NFT module.

def lock(
    module: Module,
    nftID: NFTID
) -> None:

    if module == NFT_NOT_LOCKED:
        raise Exception("Cannot be locked by NFT module")

    try:
        nft = getNFT(nftID)
    except:
        emitFailedLockEvent(module, nftID, RESULT_NFT_DOES_NOT_EXIST)
        raise Exception("NFT does not exist")
    
    if isNFTEscrowed(nft):
        emitFailedLockEvent(module, nftID, RESULT_NFT_ESCROWED)
        raise Exception("NFT is escrowed to another chain")

    if isNFTLocked(nft):
        emitFailedLockEvent(module, nftID, RESULT_NFT_LOCKED)
        raise Exception("NFT is already locked")

    owner = nft["owner"]

    userStore[owner, nftID].lockingModule = module

    emitEvent(
        module = MODULE_NAME_NFT,
        name = EVENT_NAME_LOCK,
        data = {
            "module": module,
            "nftID": nftID,
            "result": RESULT_SUCCESSFUL
        },
        topics = [module, nftID]
    )

def emitFailedLockEvent(
    module: Module,
    nftID: NFTID,
    result: uint32
) -> None:

    emitPersistentEvent(
        module = MODULE_NAME_NFT,
        name = EVENT_NAME_LOCK,
        data = {
            "module": module,
            "nftID": nftID,
            "result": result
        },
        topics = [module, nftID]
    )

unlock

This function unlocks an NFT that was previously locked to a module.

def unlock(
    module: Module,
    nftID: NFTID
) -> None:

    try:
        nft = getNFT(nftID)
    except:
        emitFailedUnlockEvent(module, nftID, RESULT_NFT_DOES_NOT_EXIST)
        raise Exception("NFT does not exist")

    if isNFTEscrowed(nft):
        emitFailedUnlockEvent(module, nftID, RESULT_NFT_ESCROWED)
        raise Exception("NFT is escrowed to another chain")

    if not isNFTLocked(nft):
        emitFailedUnlockEvent(module, nftID, RESULT_NFT_NOT_LOCKED)
        raise Exception("NFT is not locked")

    lockingModule = nft["lockingModule"]

    if lockingModule != module:
        emitFailedUnlockEvent(module, nftID, RESULT_UNAUTHORIZED_UNLOCK)
        raise Exception("Unlocking NFT via module that did not lock it")

    owner = nft["owner"]

    userStore[owner, nftID].lockingModule = NFT_NOT_LOCKED
   
    emitEvent(
        module = MODULE_NAME_NFT,
        name = EVENT_NAME_UNLOCK,
        data = {
            "module": module,
            "nftID": nftID,
            "result": RESULT_SUCCESSFUL
        },
        topics = [module, nftID]
    )

def emitFailedUnlockEvent(
    module: Module,
    nftID: NFTID,
    result: uint32
) -> None:

    emitPersistentEvent(
        module = MODULE_NAME_NFT,
        name = EVENT_NAME_UNLOCK,
        data = {
            "module": module,
            "nftID": nftID,
            "result": result
        },
        topics = [module, nftID]
    )

setAttributes

This function modifies the attributes of an NFT.

def setAttributes(
    module: Module,
    nftID: NFTID,
    attributes: bytes
) -> None:

    if NFTStore[nftID] does not exist:
        emitFailedSetAttributesEvent(nftID, attributes, RESULT_NFT_DOES_NOT_EXIST)
        raise Exception("NFT substore entry does not exist")

    NFTStore[nftID].attributesArray[module] = attributes

    emitEvent(
        module = MODULE_NAME_NFT,
        name = EVENT_NAME_SET_ATTRIBUTES,
        data = {
            "nftID": nftID,
            "attributes": attributes,
            "result": RESULT_SUCCESSFUL
        },
        topics = [nftID]
    )

def emitFailedSetAttributesEvent(
    module: Module,
    nftID: NFTID,
    attributes: bytes,
    result: uint32
) -> None:

    emitPersistentEvent(
        module = MODULE_NAME_NFT,
        name = EVENT_NAME_SET_ATTRIBUTES,
        data = {
            "nftID": nftID,
            "attributes": attributes,
            "result": result
        },
        topics = [nftID]
    )

transfer

This function transfers ownership of an NFT within one chain.

def transfer(
    senderAddress: Address,
    recipientAddress: Address,
    nftID: NFTID
) -> None:

    verifyTransferInternal(senderAddress, recipientAddress, nftID, True)
    transferInternal(senderAddress, recipientAddress, nftID)

transferCrossChain

This function transfers ownership of an NFT across chains in the Lisk ecosystem.

def transferCrossChain(
    senderAddress: Address,
    recipientAddress: Address,
    nftID: NFTID,
    receivingChainID: ChainID,
    messageFee: uint64,
    data: str,
    includeAttributes: bool
) -> None:

    verifyTransferCrossChainInternal(
        senderAddress, 
        recipientAddress, 
        nftID, 
        receivingChainID, 
        messageFee, 
        data, 
        includeAttributes,
        True
    )

    transferCrossChainInternal(
        senderAddress,
        recipientAddress,
        nftID,
        receivingChainID,
        messageFee,
        data,
        includeAttributes
    )

recover

This function should only be called by the interoperability module. It recovers an NFT escrowed to a terminated chain.

def recover(
    terminatedChainID: ChainID,
    substorePrefix: bytes,
    storeKey: bytes,
    storeValue: bytes
) -> None:

    if (
        substorePrefix != SUBSTORE_PREFIX_NFT
        or len(storeKey) != LENGTH_NFT_ID
        or storeValue cannot be deserialized using NFTStoreSchema
    ):
        emitFailedRecoverEvent(terminatedChainID, nftID, RESULT_RECOVER_FAIL_INVALID_INPUTS)
        raise Exception("Invalid inputs")

    chainID = storeKey[:LENGTH_CHAIN_ID]
    nftID = storeKey
    nftValue = decode(schema = NFTStoreSchema, object = storeValue)

    if getChainID(nftID) != OWN_CHAIN_ID:
        emitFailedRecoverEvent(terminatedChainID, nftID, RESULT_INITIATED_BY_NONNATIVE_CHAIN)
        raise Exception("Recovery called by a foreign chain")

    try:
        nft = getNFT(nftID)
    except:
        emitFailedRecoverEvent(terminatedChainID, nftID, RESULT_NFT_NOT_FOUND)
        raise Exception("NFT substore entry does not exist")

    owner = nft["owner"]
    attributesArray = nft["attributesArray"]     

    if owner != terminatedChainID:
        emitFailedRecoverEvent(terminatedChainID, nftID, RESULT_NFT_NOT_ESCROWED)
        raise Exception("NFT was not escrowed to terminated chain")

    if len(nftValue.owner) != LENGTH_ADDRESS:
        emitFailedRecoverEvent(terminatedChainID, nftID, RESULT_INVALID_ACCOUNT)
        raise Exception("Invalid account information")

    NFTStore[nftID].owner = nftValue.owner
    storedAttributes = attributesArray
    receivedAttributes = nftValue.attributes
    NFTStore[nftID].attributes = getNewAttributes(nftID, storedAttributes, receivedAttributes)
    createUserEntry(nftValue.owner, nftID)
    delete entry escrowStore[terminatedChainID, nftID] from the escrow substore

    emitEvent(
        module = MODULE_NAME_NFT,
        name = EVENT_NAME_RECOVER,
        data = {
            "terminatedChainID": terminatedChainID,
            "nftID": nftID,
            "result": RESULT_SUCCESSFUL
        },
        topics = [nftID]
    )

def emitFailedRecoverEvent(
    terminatedChainID: ChainID,
    nftID: NFTID,
    result: uint32
) -> None:

    emitPersistentEvent(
        module = MODULE_NAME_NFT,
        name = EVENT_NAME_RECOVER,
        data = {
            "terminatedChainID": terminatedChainID,
            "nftID": nftID,
            "result": result
        },
        topics = [nftID]
    )

supportAllNFTs

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

def supportAllNFTs() -> None:

    if supportedNFTsStore[ALL_SUPPORTED_NFTS_KEY] exists:
        return

    remove all entries from the supported NFTs substore

    create substore entry with
        substorePrefix = SUBSTORE_PREFIX_SUPPORTED_NFTS
        key = ALL_SUPPORTED_NFTS_KEY
        value = encode(
            schema = supportedNFTsStoreSchema,
            object = {"supportedCollectionIDArray": []}
        )

    emitEvent(
        module = MODULE_NAME_NFT,
        name = EVENT_NAME_SUPPORT_ALL_NFTS,
        data = {},
        topics = []
    )

removeSupportAllNFTs

This function removes support for all non-native NFTs.

def removeSupportAllNFTs() -> None:

    remove all entries from the supported NFTs substore

    emitEvent(
        module = MODULE_NAME_NFT,
        name = EVENT_NAME_REMOVE_SUPPORT_ALL_NFTS,
        data = {},
        topics = []
    )

supportAllNFTsFromChain

This function updates the supported NFTs substore to support all non-native NFTs of a specified foreign chain.

def supportAllNFTsFromChain(chainID: ChainID) -> None:

    if chainID == OWN_CHAIN_ID:
        return

    if supportedNFTsStore[ALL_SUPPORTED_NFTS_KEY] exists:
        return

    if supportedNFTsStore[chainID] exists:
        supportedNFTsStore[chainID] = {"supportedCollectionIDArray": []}

    else:
        create substore entry with
            substorePrefix = SUBSTORE_PREFIX_SUPPORTED_NFTS
            key = chainID
            value = encode(
                schema = supportedNFTsStoreSchema,
                object = {"supportedCollectionIDArray": []}
            )

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

removeSupportAllNFTsFromChain

This function removes support for all non-native NFTs of a specified foreign chain.

def removeSupportAllNFTsFromChain(chainID: ChainID) -> None:

    if chainID == OWN_CHAIN_ID:
        raise Exception("Invalid operation. Support for native NFTs cannot be removed.")

    if supportedNFTsStore[ALL_SUPPORTED_NFTS_KEY] exists:
        raise Exception("Invalid operation. All NFTs from all chains are supported.")

    if supportedNFTsStore[chainID] does not exist:
        return

    delete entry supportedNFTsStore[chainID] from the supported NFTs substore

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

supportAllNFTsFromCollection

This function updates the supported NFTs substore to support all non-native NFTs of a specified collection.

def supportAllNFTsFromCollection(chainID: ChainID, collectionID: CollectionID) -> None:

    if chainID == OWN_CHAIN_ID:
        return

    if supportedNFTsStore[ALL_SUPPORTED_NFTS_KEY] exists:
        return

    if supportedNFTsStore[chainID] exists:
        if supportedNFTsStore[chainID].supportedCollectionIDArray == []:
            return

        add collectionID to supportedNFTsStore[chainID].supportedCollectionIDArray, maintaining the array in lexicographical order

    else:
        create an entry in the supported NFTs substore with
        key = chainID
        value = encode(
            schema = supportedNFTsStoreSchema,
            object = {"supportedCollectionIDArray": [collectionID]}
        )

    emitEvent(
        module = MODULE_NAME_NFT,
        name = EVENT_NAME_SUPPORT_ALL_NFTS_FROM_COLLECTION,
        data = {
            "chainID": chainID,
            "collectionID": collectionID
        },
        topics = [chainID, collectionID]
    )

removeSupportAllNFTsFromCollection

This function removes support for all non-native NFTs of a specified collection.

def removeSupportAllNFTsFromCollection(chainID: ChainID, collectionID: CollectionID) -> None:

    if chainID == OWN_CHAIN_ID:
        raise Exception("Invalid operation. Support for native NFTs cannot be removed.")
    
    if supportedNFTsStore[ALL_SUPPORTED_NFTS_KEY] exists:
        raise Exception("Invalid operation. All NFTs from all chains are supported.")

    if supportedNFTsStore[chainID] exists:
        if supportedNFTsStore[chainID].supportedCollectionIDArray == []:
            raise Exception("Invalid operation. All NFTs from the specified chain are supported.")
        if there exist an item in array supportedNFTsStore[chainID].supportedCollectionIDArray with value collectionID:
            remove collectionID from supportedNFTsStore[chainID].supportedCollectionIDArray
            if supportedNFTsStore[chainID].supportedCollectionIDArray is empty:
                remove supportedNFTsStore[chainID] from the supported NFTs substore

    emitEvent(
        module = MODULE_NAME_NFT,
        name = EVENT_NAME_REMOVE_SUPPORT_ALL_NFTS_FROM_COLLECTION,
        data = {
            "chainID": chainID,
            "collectionID": collectionID
        },
        topics = [chainID, collectionID]
    )

Genesis Block Processing

Genesis Assets Schema

genesisNFTStoreSchema = {
    "type": "object",
    "required": [
        "NFTSubstore",
        "supportedNFTsSubstore"
    ],
    "properties": {
        "NFTSubstore": {
            "type": "array",
            "fieldNumber": 1,
            "items": {
                "type": "object",
                "required": [
                    "nftID",
                    "owner",
                    "attributesArray"
                ],
                "properties": {
                    "nftID": {
                        "dataType": "bytes",
                        "length": LENGTH_NFT_ID,
                        "fieldNumber": 1
                    },
                    "owner": {
                        "dataType": "bytes",
                        "fieldNumber": 2
                    },
                    "attributesArray": {
                        "type": "array",
                        "fieldNumber": 3,
                        "items": {
                            "type": "object",
                            "required": ["module", "attributes"],
                            "properties": {
                                "module": {
                                    "dataType": "string",
                                    "minLength": MIN_LENGTH_MODULE_NAME,
                                    "maxLength": MAX_LENGTH_MODULE_NAME,
                                    "pattern": "^[a-zA-Z0-9]*$",
                                    "fieldNumber": 1
                                },
                                "attributes": {
                                    "dataType": "bytes",
                                    "fieldNumber": 2
                                }
                            }
                        }
                    }
                }
            }
        },
        "supportedNFTsSubstore": {
            "type": "array",
            "fieldNumber": 2,
            "items": {
                "type": "object",
                "required": [
                    "chainID",
                    "supportedCollectionIDArray"
                ],
                "properties": {
                    "chainID": {
                        "dataType": "bytes",
                        "fieldNumber": 1
                    },
                    "supportedCollectionIDArray": {
                        "type": "array",
                        "fieldNumber": 2,
                        "items": {
                            "dataType": "bytes",
                            "length": LENGTH_COLLECTION_ID
                        }
                    }
                }
            }
        }
    }
}

Genesis State Initialization

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

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

  • Initial checks on the properties of genesisBlockAssetObject:

    • The NFTSubstore must be in lexicographical order of nftID. Across all elements of the NFTSubstore array:
      • All values given for nftID must be unique.
      • All values given for owner must have either length LENGTH_ADDRESS bytes (representing a user address) or LENGTH_CHAIN_ID bytes (representing a chain ID).
      • The attributesArray must be in lexicographic order of module name, and all values of module must be unique.
    • The supportedNFTsSubstore array, if non-empty, must adhere to one of the following two mutually exclusive conditions:
      • Have a single entry with chainID of length 0 and no other entries. This indicates that all NFTs are supported, i.e., chainID == ALL_SUPPORTED_NFTS_KEY. In this case, the supportedCollectionIDArray should be an empty array.
      • Otherwise, across all elements, the chainID must be unique and have length LENGTH_CHAIN_ID. Furthermore, the supportedNFTsSubstore array must be in lexicographic order of chainID. Also, for each entry of this array, the supportedCollectionIDArray should be in lexicographic order.
  • For each entry NFTEntry in genesisBlockAssetObject.NFTSubstore:

    • Create an entry in the NFT substore with:

      storeKey = NFTEntry.nftID
      storeValue = encode(
          schema = NFTStoreSchema,
          object = {
              "owner": NFTEntry.owner,
              "attributesArray": NFTEntry.attributesArray
          }
      )
    • If NFTEntry.owner has length LENGTH_ADDRESS bytes, create an entry in the user substore with:

      storeKey = NFTEntry.owner + NFTEntry.nftID
      storeValue = encode(
          schema = userStoreSchema,
          object = {
              "lockingModule": NFT_NOT_LOCKED
          }
      )
    • If NFTEntry.owner has length LENGTH_CHAIN_ID bytes, create an entry in the escrow substore with:

      storeKey = NFTEntry.owner + NFTEntry.nftID
      storeValue = EMPTY_BYTES
  • For each entry supportedNFTsEntry in genesisBlockAssetObject.supportedNFTsSubstore, create an entry in the supported NFTs substore with:

    storeKey = supportedNFTsEntry.chainID
    storeValue = encode(
        schema = supportedNFTsStoreSchema,
        object = {
            "supportedCollectionIDArray": [collectionID for each collectionID in supportedNFTsEntry.supportedCollectionIDArray]
        }
    )

Endpoints for Off-Chain Services

This section specifies the non-trivial or recommended endpoints of the NFT module and does not include all endpoints.

hasNFT

This function checks whether an NFT exists and is owned by a specific user.

def hasNFT(address: Address, nftID: NFTID) -> bool:
    if NFTStore[nftID] does not exist:
        return False
    return NFTStore[nftID].owner == address

getNFT

This function has exactly the same logic, inputs and outputs as the getNFT function specified in Protocol Logic for Other Modules section.

getNFTs

This function returns NFT and user substores information for all NFTs owned by a specific user.

def getNFTs(address: Address) -> list[dict]:
    ownedNFTIDs = [nftID for nftID in NFTStore if NFTStore[nftID].owner == address]
    ownedNFTIDsInfo = []
    for nftID in ownedNFTIDs:
        attributesArray = [
            {
                "module": attribute.module,
                "attributes": attribute.attributes
            }
            for attribute in NFTStore[nftID].attributesArray
        ]
        lockingModule = userStore[address, nftID].lockingModule
        ownedNFTIDInfo = {
            "nftID": nftID,
            "attributesArray": attributesArray,
            "lockingModule": lockingModule
        }
        ownedNFTIDsInfo.append(ownedNFTIDInfo)
    return ownedNFTIDsInfo

getEscrowedNFTIDs

This function returns a list of all escrowed native NFTs for a given chainID.

def getEscrowedNFTIDs(chainID: ChainID) -> list[NFTID]:
    escrowedNFTIDs = [nftID for nftID in NFTStore if NFTStore[nftID].owner == chainID]
    return escrowedNFTIDs

Backwards Compatibility

Chains adding support for the NFT module specified in this document need to do so with a hard fork. This proposal does not imply a fork for the Lisk mainchain.

Reference Implementation

Introduce non-fungible token module