Introduce a non-fungible token module

Hello,

In this thread, I want to propose a LIP for the roadmap objective Introduce token standards. The proposal’s contribution is to define a non-fungible token module to be used in the Lisk ecosystem.

I’m looking forward to your feedback.

Here is a complete LIP draft:

LIP:
Title: Introduce a non-fungible token module
Author: Maxime Gagnebin <maxime.gagnebin@lightcurve.io>
Type: Standards Track
Created: <YYYY-MM-DD>
Updated: <YYYY-MM-DD>

Abstract

This topic introduces an NFT (non-fungible token) module to be 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 a list of attributes that are used to store information specific to the NFT.

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 4 parts. All NFTs held by users are stored sequentially in the user store with keys given by the user address and the NFT ID.

NFT Store

The NFT store contains entries for all NFTs present on the chain, as well as entries for all native NFTs that have been sent cross-chain. Each entry contains three properties, the owner, the locking module ID and the attributes of the NFT. The owner can either be a 20 bytes user address, or a 4 bytes serialization of a chain ID. In the latter case, the token has been sent cross-chain.

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

Lastly, the NFT stores an attribute property which can be used by custom applications to store information about the NFT, or modify interactions with the NFT.

User Store

In the proposed solution, all NFTs associated with a given address are stored sequentially in the user store 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 store is designed accordingly.

NFT Identifier

To identify NFTs in the Lisk ecosystem, we introduce the NFT ID in this proposal.
An NFT ID will be unique in the ecosystem. It is built from 3 integers: the chain ID of the chain creating the token, a collection integer chosen when the token is created and an index which is automatically assigned to the new NFT.

This allows chains to define multiple sets of NFTs, each identified by their respective collection. For example, an art NFT exchange could have a different collection per artist.
The index being then the 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 transaction 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 chain can correctly escrow its native tokens sent to other chains. 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 to and from their native chain. In particular, this means that a token created on chain A cannot be transferred directly from chain B to chain C. This is required to allow the native chain to maintain correctly escrowed NFTs.

Attributes

Each NFT is stored with an attribute property. This property is a byte sequence that is not deserialized by the NFT module. Each custom module using an NFT collection should define schemas to serialize and deserialize the attribute property of NFTs of their collection.

When an NFT is sent to another chain, the attributes property of the NFT can be modified according to specifications set on the receiving chain. For this reason, custom modules specifying an NFT collection must also implement the behavior to adopt when an NFT is returned with a modified attributes property. This custom behavior will compare the returned attributes with the ones stored with the escrowed NFT. If the returned NFT has an empty attribute, the native chain will restore the attributes as stored, this can be used to save on cross-chain transaction size when returning non-modified NFTs to their native chains.

Reducers

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

create

This reducer is used to create a new NFT. The NFT will always be native to the chain creating it. The index of the created NFT will be the next available index, as specified by the max index corresponding to the collection.

destroy

This reducer is used to destroy NFTs. The NFT will be removed from the NFT store and cannot be retrieved. The use of this reducer is limited to destroying native NFTs.

transfer

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

transferCrossChain

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

lock

This reducer is used to lock an NFT to a module ID. 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. A module ID is specified when locking the NFT and this ID has to be specified when unlocking the NFT. This avoids NFTs being accidentally locked and unlocked by different modules.

unlock

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

setAttributes

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

Specification

Constants and Notations

The following constants are used throughout the document

Name Type Value
Interoperability Constants
MODULE_ID_INTEROPERABILITY uint32 64
MAINCHAIN_ID uint32 1
MIN_RETURN_FEE uint64 1000
NFT Module Constants
MODULE_ID_NFT uint32 TBD
ASSET_ID_TRANSFER uint32 0
ASSET_ID_CROSS_CHAIN_TRANSFER uint32 1
ASSET_ID_CCM_CROSS_CHAIN_TRANSFER uint32 2
NFT_NOT_LOCKED uint32 0
MAX_SIZE_ATTRIBUTES uint32 0
CCM_STATUS_OK uint32 0
CCM_STATUS_NFT_NOT_SUPPORTED uint32 5
CCM_STATUS_ESCROW_VIOLATION uint32 6
Token Store Constants
STORE_PREFIX_NFT bytes 0x00 00
STORE_PREFIX_USER bytes 0x80 00
STORE_PREFIX_COLLECTION bytes 0xc0 00
STORE_PREFIX_VIOLATION bytes 0xd0 00

uint32be

uint32be(x) returns the big endian uint32 serialization of an integer x, with 0 <= x <2^32. This serialization is always 4 bytes long.

uint64be

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

ownChainID

In this LIP, ownChainID is the chain ID of the chain under consideration, it is stored in the “own chain account” entry of the interoperability store. This value only exists on chains having an initialized interoperability store.

Reducers from Other Modules

Calling a reducer reducer from the interoperability module module is represented by interoperability.reducer(required inputs).

NFT Module Store

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

  • NFT store

    • storagePrefix = STORE_PREFIX_NFT.
    • storageKey = uint32be(chainID)||uint32be(collection)||uint64be(index).
    • storageValue serialized using NFTStorageSchema.
    NFTStorageSchema = {
        "type": "object",
        "properties": {
            "owner": { 
                "dataType": "bytes", 
                "fieldNumber": 1 
            },
            "lockingModuleID": { 
                "dataType": "uint32", 
                "fieldNumber": 2 
            },
            "attributes": {             
                "dataType": "bytes", 
                "fieldNumber": 3 
            }
        },
        "required": [
            "owner",
            "lockingModuleID",
            "attributes"
        ]
    }
    
  • User store

    • storagePrefix = STORE_PREFIX_USER.
    • storageKey = address||uint32be(chainID)||uint32be(collection)||uint64be(index).
    • storageValue = 0x01.
  • Collection store

    • storagePrefix = STORE_PREFIX_COLLECTION.
    • storageKey = uint32be(collection).
    • storageValue serialized using collectionStorageSchema.
    collectionStorageSchema = {
        "type": "object",
        "properties": {
            "maxIndex": { 
                "dataType": "uint64", 
                "fieldNumber": 1
            },
        },
        "required": ["maxIndex"]
    }
    
  • Terminated Escrow

    • storagePrefix = STORE_PREFIX_TERMINATED_ESCROW.
    • storageKey = uint32be(chainID).
    • storageValue serialized using terminatedEscrowStorageSchema.
    terminatedEscrowStorageSchema = {
        "type": "object",
        "properties": {
            "status": { 
                "dataType": "boolean", 
                "fieldNumber": 1
            },
        },
        "required": ["status"]
    }
    

Store Notation

For the rest of this proposal:

  • Let NFTStore(chainID, collection, index) be the NFT store entry with storagePrefix = STORE_PREFIX_NFT and storageKey = uint32be(chainID)||uint32be(collection)||uint64be(index).
    Further, define:

    lockingModuleID(chainID,collection,index) 
    = NFTStore(chainID,collection,index).lockingModuleID 
    
    owner(chainID,collection,index)
    = NFTStore(chainID,collection,index).owner
    
    attributes(chainID,collection,index)
    = NFTStore(chainID,collection,index).attributes
    
  • Let userStore(address, chainID, collection, index) be the NFT store entry with storagePrefix = STORE_PREFIX_USER and storageKey = address||uint32be(chainID)||uint32be(collection)||uint64be(index).

  • Let collectionStore(collection) be the NFT store entry with storagePrefix = STORE_PREFIX_COLLECTION and storageKey = uint32be(collection).

  • Let terminatedStoreStatus(chainID) be the status property of the NFT store entry with storagePrefix = STORE_PREFIX_TERMINATED_ESCROW and storageKey = uint32be(chainID). If the store entry does not exist, the function returns false.

NFT Identification

All NFTs in the ecosystem are identified by the tuple (chainID, collection, index). chainID is always the chain ID of the chain that created the NFT and collection is an integer specified at NFT creation, index will be assigned at NFT creation to the next available index in the collection.

NFT ID and Native NFT

NFTs on their native chain are identified by the tuple (0, collection, index). The same NFTs in other chains would be identified by the tuple (chainID, collection, index), chainID being the chain ID of the chain where the NFT was created.

Supported NFTs

All chains implementing the NFT module must implement a NFTSupported(chainID,collection) method that returns true or false depending if the given NFT collection is supported or not by the chain. This function is used when receiving cross-chain NFT transfer to assert the support for non-native NFTs.

Internal Functions

createNFTEntry

createNFTEntry(chainID, collection, index, address, moduleID, givenAttributes):
    create a store entry with
        storagePrefix = STORE_PREFIX_NFT
        storageKey = address 
                     || uint32be(chainID) 
                     || uint32be(collection) 
                     || uint64be(index)

        storageValue = { 
            owner: address, 
            lockingModuleID: moduleID, 
            attributes: attributes serialized using NFTStorageSchema
        }

eraseNFTEntry

eraseNFTEntry(chainID, collection, index):
    erase the store entry with
        storagePrefix = STORE_PREFIX_NFT
        storageKey = uint32be(chainID) 
                     || uint32be(collection) 
                     ||uint64be(index)

createUserEntry

createUserEntry(address, chainID, collection, index):
    create an store entry with
        storagePrefix = STORE_PREFIX_USER
        storageKey = address 
                     || uint32be(chainID) 
                     || uint32be(collection) 
                     ||uint64be(index)
        storageValue = 0x01

eraseUserEntry

eraseUserEntry(address, chainID, collection, index):
    erase the store entry with
        storagePrefix = STORE_PREFIX_USER
        storageKey = address 
                     || uint32be(chainID) 
                     || uint32be(collection) 
                     || uint64be(index)

terminateEscrow

terminateEscrow(chainID):
    create the store entry with
        storagePrefix = STORE_PREFIX_TERMINATED_ESCROW.
        storageKey    = uint32be(chainID)
        storageValue  = true serialized according to terminatedEscrowStorageSchema

NFT Attributes

For all NFT collections, native chains must implement the function
getNewAttributes(collection, storedAttributes, receivedAttributes) which is used whenever an NFT from this collection is received from another chain. getNewAttributes must always return a byte array shorter than MAX_SIZE_ATTRIBUTES bytes.

For all values of collection and storedAttributes, this function must be defined as getNewAttributes(collection, storedAttributes, EMPTY_BYTES) = storedAttributes.

This function’s default behavior is to always overwriting the received attributes with the ones in the escrow store:

defaultGetNewAttributes(collection, storedAttributes, receivedAttributes):
    return storedAttributes

NFTs in Genesis Blocks

The genesis block of a chain can have a non-empty NFT store. The distribution of NFTs at genesis is left to sidechain developers and must only follow few restrictions:

  • No escrow entries (entries with storagePrefix = ESCROW_STORE_PREFIX) should exist in the genesis block.
  • Only NFTs with chainID == 0 must exist in the genesis block. They must all be included in user accounts, and can have any uint32 value for the lockingModule property.
  • For all collections, the maximal index of all NFTs of this collection, over all existing user entries, must be strictly smaller than collectionStore(collection).maxIndex.

Transactions

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

NFT Transfer Transaction

This transaction has:

  • moduleID = NFT_MODULE_ID
  • assetID = 0
Asset Schema

The asset property of the NFT transfer transaction follows the schema NFTTransferAsset.

NFTTransferAsset = {
    "type": "object",
    "properties": {
        "chainID": {
            "dataType": "bytes",
            "fieldNumber": 1
        },
        "collection": {
            "dataType": "bytes",
            "fieldNumber": 2
        },
        "index": {
            "dataType": "bytes",
            "fieldNumber": 3
        },
        "recipientAddress": {
            "dataType": "bytes",
            "fieldNumber": 4
        },
    },
    "required": [ 
        "chainID" , 
        "collection", 
        "index", 
        "recipientAddress" 
    ]
}
Asset Validity

The asset is valid if

  • recipientAddress is a byte array of length 20,
  • its application does not fail.
Transaction Application

When applying this transaction, the following is done:

derive senderAddress from trs.senderPublicKey
let chainID, collection, index as given in trs.asset

if lockingModuleID(chainID, collection, index) != NFT_NOT_LOCKED:   
    transaction application fails  
if owner(chainID, collection, index) != senderAddress:   
    transaction application fails

createUserEntry(recipientAddress, chainID, collection, index)
eraseUserEntry(senderAddress, chainID, collection, index)
owner(chainID, collection, index) = recipientAddress

Cross-chain NFT Transfer Transaction

This transaction has:

  • moduleID = NFT_MODULE_ID
  • assetID = 1
Asset Schema

The asset property of cross-chain NFT transfer transactions follows the schema crossChainTransferAsset.

crossChainTransferAsset = {
    "type": "object",
    "properties": {
        "chainID": {
            "dataType": "uint32",
            "fieldNumber": 1
        },
        "collection": {
            "dataType": "uint32",
            "fieldNumber": 2
        },
        "index": {
            "dataType": "uint64",
            "fieldNumber": 3
        },
        "receivingChainID": {
            "dataType": "uint32",
            "fieldNumber": 4 
        },
        "recipientAddress": {
            "dataType": "bytes",
            "fieldNumber": 5 
        },
        "messageFee": {
            "dataType": "uint64",
            "fieldNumber": 6 
        }
    },
    "required":[
        "chainID", 
        "collection", 
        "index" ,   
        "receivingChainID", 
        "recipientAddress", 
        "messageFee" 
    ]
}
Asset Validity

The asset is valid if

  • recipientAddress is a byte array of length 20,
  • chainID is either 0 or receivingChainID,
  • its application does not fail.
Transaction Application

When applying a cross-chain NFT transfer transaction trs, the following is done:

  • Derive senderAddress from trs.senderPublicKey.

  • Apply the same logic as the reducer

    transferCrossChain(senderAddress, 
                       trs.asset.receivingChainID, 
                       trs.asset.recipientAddress, 
                       trs.asset.chainID, 
                       trs.asset.collection, 
                       trs.asset.index, 
                       trs.asset.messageFee)
    

Applying Cross-chain Messages

Cross-chain NFT Transfer Message

This CCM has:

  • moduleID = NFT_MODULE_ID,
  • assetID = 2
Message Asset Schema

The asset property of cross-chain NFT transfers follows the crossChainTransferMessageAsset schema.

crossChainTransferMessageAsset = {
    "type": "object",
    "properties": {
        "chainID": {
            "dataType": "uint32",
            "fieldNumber": 1
        },
        "collection": {
            "dataType": "uint32",
            "fieldNumber": 2
        },
        "index": {
            "dataType": "uint64",
            "fieldNumber": 3
        },
        "senderAddress": {
            "dataType": "bytes",
            "fieldNumber": 5 
        },
        "recipientAddress": {
            "dataType": "bytes",
            "fieldNumber": 5 
        },
        "attributes": {
            "dataType": "bytes",
            "fieldNumber": 6 
        }
    },
    "required": [
        "chainID", 
        "collection", 
        "index" ,   
        "recipientAddress", 
        "attributes" 
    ]
}
Message Application

When applying a cross-chain message CCM, the logic below is followed.

chainID            = CCM.asset.chainID
collection         = CCM.asset.collection
index              = CCM.asset.index
sendingChainID     = CCM.sendingChainID
senderAddress      = CCM.asset.senderAddress
recipientAddress   = CCM.asset.recipientAddress
receivedAttributes = CCM.asset.attributes

if chainID not in [ownChainID, CCM.sendingChainID]:
    terminateEscrow(sendingChainID)
    if CCM.status == CCM_STATUS_OK 
    and CCM.fee >= MIN_RETURN_FEE*size(CCM):
        interoperability.error(CCM, CCM_STATUS_ESCROW_VIOLATION)
    exit CCM application

if chainID != ownChainID and CCM.status != CCM_STATUS_OK:
    exit CCM application

if chainID == ownChainID:
    if owner(chainID,collection,index) != CCM.sendingChainID:
        terminateEscrow(sendingChainID)
        if CCM.status == CCM_STATUS_OK 
        and CCM.fee >= MIN_RETURN_FEE*size(CCM):
            CCM.asset = "" // set to empty bytes
            interoperability.error(CCM, CCM_STATUS_ESCROW_VIOLATION)
        exit CCM application


    oldAttributes = attributes(chainID,collection,index)
    if CCM.status == CCM_STATUS_OK or CCM.status == CCM_STATUS_RECOVERED:   
        newAttributes = getNewAttributes(collection,
                                         oldAttributes,
                                         receivedAttributes) 
        newRecipientAddress = CCM.asset.recipientAddress 
    else:
        newAttributes = oldAttributes
        newRecipientAddress = CCM.asset.senderAddress

    owner(0,collection,index) = newRecipienAddress
    attributes(0,collection,index) = newAttributes
    createUserEntry(newRecipientAddress, 0, collection, index)

else: // chainID != ownChainID
    if CCM.status != CCM_STATUS_OK:
        exit CCM application
    elif NFTSupported(chainID,collection) == FALSE:
        if fee >= MIN_RETURN_FEE*size(CCM):
            interoperability.error(CCM, CCM_STATUS_NFT_NOT_SUPPORTED)
        exit CCM application
    else:
        createNFTEntry(chainID, 
                       collection, 
                       index, 
                       recipientAddress, 
                       receivedAttributes)
        createUserEntry(recipientAddress, chainID, collection, index)

Reducers

getAttributes

getAttributes(address, chainID, collection, index):
    if NFTStore(chainID,collection,index) exists:
        return attributes(chainID, collection, index)
    else:
        return entry does not exist

getLockingModuleID

getLockingModuleID(address, chainID, collection, index):
    if NFTStore(chainID,collection,index) exists:
        return lockingModuleID(chainID,collection,index)
    else:
        return entry does not exist

getNFTowner

getNFTowner(chainID, collection,index):
    if NFTStore(chainID,collection,index) exists:
        return owner(chainID, collection, index)
    else:
        return entry does not exist

isTerminated

isTerminated(chainID):
    if terminatedStoreStatus(chainID) == true:
        return true
    else:
        return false

getMaxIndex

getMaxIndex(collection):
    if there is no entry in the NFT store with 
    storagePrefix = STORE_PREFIX_COLLECTION 
    storageKey = uint32be(collection):
        return collection does not exist
        
    return collectionStore(collection).maxIndex 

create

create(address, collection, attributes):
    if size(attributes) > MAX_SIZE_ATTRIBUTES bytes:
        reducer fails
    if collectionStore(collection) does not exist:
        reducer fails
        
    index = collectionStore(collection).maxIndex
    createNFTEntry(0,collection,index,address,attributes)   
    createUserEntry(address, 0, collection, index)
    collectionStore(collection).maxIndex += 1

destroy

destroy(address, collection, index):
    if NFTStore(0, collection, index) does not exist:
        reducer fails
        
    address = owner(0, collection, index)
    eraseNFTEntry(0, collection, index)
    eraseUserEntry(address, 0, collection, index) 

initializeCollection

initializeCollection(collection):
    if collectionStore(collection) exists:
        reducer fails

    create an NFT store entry with
        storagePrefix = STORE_PREFIX_ESCROW
        storageKey = uint32be(collection) || uint64be(index)
        storageValue = {maxIndex: 0} serialized using collectionStorageSchema 

transfer

transfer(senderAddress, recipientAddress, chainID, collection, index): 
    if lockingModuleID(chainID, collection, index) != NFT_NOT_LOCKED:
        reducer fails  
    if owner(chainID, collection, index) != senderAddress:   
        reducer fails

    createUserEntry(recipientAddress, chainID, collection, index)
    eraseUserEntry(senderAddress, chainID, collection, index)
    owner(chainID, collection, index) = recipientAddress

transferCrossChain

transferCrossChain(senderAddress, 
                   receivingChainID, 
                   recipientAddress, 
                   chainID, 
                   collection, 
                   index, 
                   messageFee): 

    if chainID not in [0, receivingChainID]:
        reducer fails
    if owner(chainID,collection,index) != senderAddress:
        reducer fails
    if lockingModuleID(chainID,collection,index) != NFT_NOT_LOCKED:
        reducer fails
    if terminatedStoreStatus(sendingChainID) == true and chainID == 0:
        reducer fails

    attributes = attributes(chainID,collection,index)

    if chainID == 0:  
        owner(0,collection, index) = uint32be(receivingChainID)
        newChainID = ownChainID
    else:
        eraseNFTEntry(chainID,collection, index)
        newChainID = chainID
    
    eraseUserEntry(address, chainID, collection, index)
    
    timestamp = timestamp of the block including the call to this reducer
    
    serializedAsset = serialization of messageAsset object below 
                  following crossChainTransferMessageAsset

    messageAsset: {
        "chainID": newChainID,
        "collection": collection,
        "index": index,
        "senderAddress": senderAddress,
        "recipientAddress": recipientAddress
        "attributes": attributes,
    }

    call interoperability.send(timestamp,
                               NFT_MODULE_ID,
                               2,
                               receivingChainID,
                               messageFee,
                               senderAddress,
                               serializedAsset)

lock

lock(address, moduleID, chainID, collection, index):  
    if lockingModuleID(chainID,collection,index) != NFT_NOT_LOCKED: 
        reducer fails 
        
    lockingModuleID(chainID,collection,index) = moduleID

unlock

unlock(address, moduleID, chainID, collection, index):
    if lockingModuleID(chainID, collection, index) != moduleID:
        reducer fails
        
    lockingModuleID(chainID, collection, index) = NFT_NOT_LOCKED

setAttributes

setAttributes(address, newAttributes, chainID, collection, index):
    if NFTStore(chainID,collection,index) does not exist:
        reducer fails
        
    attributes(chainID,collection,index) = newAttributes

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.

1 Like