RFC: Multi-Token Extensible NFT (Draft Spec)

RFC: Multi-Token Extensible NFT (Draft Spec)

Introduction

This is an alternate specification for NFTs on Nervos CKB, based off the original draft specification by Tannr Allard and taking inspiration from the ERC721, ERC998, and ERC1155 Ethereum standards.

A Non Fungible Token (NFT) is a token with unique qualities which may not be interchangeable or have equal value to other tokens within the same token class. Common uses include includes digital collectibles, video game items, and tokenized physical assets such as fine art and real estate.

The original NFT standard was created on Ethereum using ERC721, which was originally developed for the CryptoKitties game. However, it was quickly realized that the standard, while being perfectly suited for CryptoKitties, was inefficient or inadequate for the growing number of NFT use cases. ERC1155 was created a few months later to address these use cases.

This standard more closely follows ERC1155, of which the functionality encompasses both the ERC721 standard that the original draft specification is based on, and the ERC20 token standard that the SUDT standard is based on.

Key Differences and Motivations

The largest differentiators from the original draft specification are as follows:

Quantity Field

A quantity field has been included similar to ERC1155. This allows the token to function as a non-fungible token with fungible qualities. This effectively adds SUDT functionality within an NFT, and allows for more flexible interoperability with token standards on external chains.

This makes the standard suitable for more common use cases such as video game items, where a user can own more than one of a specific item. This will save a considerable amount of state space, meaning that ownership of assets will require far fewer CKBytes for a large number of similar items.

Dynamic Loading of Token Logic

Developer defined token logic is implemented using dynamic loading of deps instead of being implemented in the lock script. This allows NFTs to include custom functionality without having to rely on specific custom lock scripts.

This improves interoperability of NFTs between token platforms because it allows any lock script to be used. This is highly desirable because any platform that requires a custom lock, such as an NFT auction platform, would be incompatible otherwise.

Wallets will also benefit from this since it reduces the number of well-known lock scripts which will be included in the “white list” of safe scripts with known behavior. When new functionality introduced to a token it will not require the developer to inform the wallet of a new lock script definition.

Specification

This specification details the data structure and intended usage for an NFT. The functionality that must be present in any implementation is described including the rationale behind it.

Cell Structure

This basic cell structure of an NFT:

data:
  instance_id: * (32 bytes)
  quantity: u128 (16 bytes)
  token_logic: code_hash (32 bytes)
  custom: *
lock: <user_lock>
type:
  code_hash: NFT Code Hash (32 bytes)
  hash_type: code
  args: <governance_lock_hash>, *

The data field of the cell includes the following:

  • instance_id: The first 32 bytes represent the Instance ID. This identifier defines a token instance. These 32 bytes are a unique hash that is created when the cell is generated. The id must always be exactly 32 bytes in length.
  • quantity: The next 16 bytes are a u128 representing the quantity of the token instance in the cell. The quantity must always be exactly 16 bytes in length. This field is optional and can be omitted if it is unused. If quantity is not used by the developer but the field is present, it should be set to a default value of 1.
  • token_logic: The next 32 bytes represent the code hash of the token logic to be utilized. This hash must match the data hash of a cell dependency that contains the code which will be executed. This code contains any logic put in place by the developer to manage the token. The token logic field must always be exactly 32 bytes in length, but can be omitted if unused. If token logic is not used by the developer but the field is present, it should be set to a special value consisting of all zeros. The quantity field must be present in order for the token logic field to be used.
  • custom: Additional data can be stored beyond the first 80 bytes. This data is defined by the developer, and it’s usage is optional. The quantity field and token_logic fields must be present in order for the custom field to be used.

The user_lock can be any lock script. This specification does place any restriction on the lock which is used.

The governance_lock_hash is a 32 byte hash that defines the lock script hash of the owner of the token which has administrative privileges.

A unique type script, which is comprised of a code hash, hash type, governance lock hash, and any additional args, is what defines a token class. A token class is dictated by the type script, which in turn includes the governance lock hash. Therefore, a token class is inherently connected to a specific lock script, which may be a single user, multiple users, or another script.

Generation

The generation model allows the owner the ability to mint unlimited new token instances within the same token class. The Instance ID assigned to each new token is generated using the seed cell design pattern, which means it is assigned randomly and is guaranteed to be globally unique. The quantity of tokens in a new token instance is defined at the time of creation and cannot be increased after generation.

The Instance ID is determined as follows:

For any new token instance being created, the Instance ID is a hash of the
outpoint of the first input, combined with the output index of the token
instance cell being created.

hash(seed_cell.tx_hash, seed_cell.index, instance_output_index)

By including the output index of the token instance, multiple instances can be created in a single transaction, and each one is guaranteed to have a globally unique Instance ID.

Example for generating two token instances in a single transaction:

inputs:
    [Seed Cell]
        data: <*>
        lock: <governance_lock>
        type: <*>
outputs:
    [NFT Cell]
        data:
            <instance_id: hash(seed_cell.tx_hash, seed_cell.index, 0)>
            <quantity>
            <token_logic_hash>
            <custom>
        lock: <*>
        type:
            code_hash: NFT Code Hash (32 bytes)
            hash_type: code
            args: <governance_lock_hash>, *
    [NFT Cell]
        data:
            <instance_id: hash(seed_cell.tx_hash, seed_cell.index, 1)>
            <quantity>
            <token_logic_hash>
            <custom>
        lock: <*>
        type:
            code_hash: NFT Code Hash (32 bytes)
            hash_type: code
            args: <governance_lock_hash>, *

Constraints

  • Only the owner of a token class can mint new instances.
    hash(input[i].lock) == governor_lock_script_hash
  • The Instance ID of a cell must be unique and based on the provided seed cell.
    hash(seed_cell.tx_hash, seed_cell.index, output.index) == cell.instance_id
  • If the quantity field is used, it must be 16 bytes in length (u128). This it enforced by the constraint:
    if data.length > 32: asset data.length >= 48
  • If the token logic field is used, it must be 32 bytes in length. This it enforced by the constraint:
    if data.length > 48: assert data.length >= 80
  • If the token logic field is used, and is not zero filled, then the token logic hash must be a valid cell dep.
    cell_dep[i].data_hash == token_logic_hash

Transfer / Burn

If owner mode is not detected in a transaction, then the transaction is assumed to be a transfer or a burn. A more restrictive set of constraints then applies, which allows modifications to the lock script and quantity field only.

Example for Alice transferring three of her ten tokens to Bob:

inputs:
    [NFT Cell]
        data:
            <instance_id: ABCD>
            <quantity: 10>
            <token_logic: None>
            <custom: None>
        lock: <Alice>
        type: <token_class: XYZ>
outputs:
    [NFT Cell]
        data:
            <instance_id: ABCD>
            <quantity: 7>
            <token_logic: None>
            <custom: None>
        lock: <Alice>
        type: <token_class: XYZ>
    [NFT Cell]
        data:
            <instance_id: ABCD>
            <quantity: 3>
            <token_logic: None>
            <custom: None>
        lock: <Bob>
        type: <token_class: XYZ>

Constraints

  • Token instances can only be created from existing instances.
    token_instance_output[i].instance_id == token_instance_input[i].instance_id
  • The token instances quantity in the outputs must be less than or equal to that in the inputs. If no quantity was present, the quantity is assumed to be 1.
    sum(token_instance_output[..].quantity) <= sum(token_instance_input[..].quantity)
  • Token instances must not change the token_logic field.
    token_instance_output[i].token_logic_hash == token_instance_input[i].token_logic_hash

Note: There is no constraint for the custom data in a cell, which is all data beyond the first 80 bytes. Any developer rules for the handling of this data should be included in the token logic script.

Update (Owner Mode)

When a transaction is created by the owner, execution will allow for additional changes to be made to the cell data that are not allowed in a transfer operation. Specifically, the token_logic field can be freely updated, and any custom data stored beyond the initial 80 bytes may also be modified freely.

Example for an update operation from the owner, Alice, who transfers three of her 10 tokens to Bob, and updates the token_logic and custom data:

inputs:
    [Cell]
        data: None
        lock: <Alice>
        type: None
    [NFT Cell]
        data:
            <instance_id: ABCD>
            <quantity: 10>
            <token_logic: EDFG>
            <custom: 555>
        lock: <Alice>
        type: <token_class: XYZ owner: Alice>
outputs:
    [NFT Cell]
        data:
            <instance_id: ABCD>
            <quantity: 7>
            <token_logic: EDFG>
            <custom: 222>
        lock: <Alice>
        type: <token_class: XYZ owner: Alice>
    [NFT Cell]
        data:
            <instance_id: ABCD>
            <quantity: 3>
            <token_logic: HIJK>
            <custom: 333>
        lock: <Bob>
        type: <token_class: XYZ owner: Alice>

Constraints

  • Token instances can only be created from existing instances.
    token_instance_output[i].instance_id == token_instance_input[i].instance_id
  • The token instances quantity in the outputs must be less than or equal to that in the inputs. If no quantity was present, the quantity is assumed to be 1.
    sum(token_instance_output[..].quantity) <= sum(token_instance_input[..].quantity)

Token Logic Execution

The Token Logic Script is executed under these specific conditions:

  • The NFT Cell Data contains a Token Logic value.
  • The Token Logic code hash value is not zero filled, which would indicate the absence of a script.
  • The Script is executing a transfer or burn operation.
  • Owner Mode was not detected.

Token Logic is not executed during a generation or update operations since this would complicate the logic needed, and would complicate administrative actions on the NFT instance.

The Token Logic field should be set to a 32-byte Blake2b code hash of a Token Logic script that is compiled as a C Shared Library (cdylib). An easy way to obtain this hash is to use ckb-cli on the compiled binary:

ckb-cli util blake2b —binary-path ./build/release/token-logic.so

The binary should be added to the blockchain in a cell, and the outpoint of that cell can be included as a cell dep in a transaction where the NFT requires that specific Token Logic script.

The Token Logic script should follow the basic c-sharedlib template conventions from Capsule. At present time, Token Logic scripts must be written in C due to the lack of support for cdylib targets for RISC-V in the Rust compiler. Once support is added then we will be able to fully support Token Logic scripts written in Rust.

The Token Logic script shared lib requires a single external function which serves as the entry point for execution. This function takes a byte array containing the Token Logic code hash of the executing script itself.

// Rust Definition
fn(token_logic_code_hash: &[u8; 32]) -> i32

// C Definition
int32_t token_logic(const char* token_logic_code_hash)

The token_logic_code_hash value is included because it is necessary for proper NFT validation. Under common conditions, only those with a Token Logic value that matches the token_logic_code_hash should be processed during Token Logic script execution. If a different Token Logic value is present, then that cell should be excluded from validation since it is subject to validation by a different Token Logic script. However, this guideline may not apply for complex interactions between NFT instance types.

Script Logic Flowchart

This flowchart outlines the basic logical operation of the NFT instance.

Considerations

Token Metadata

Similar to the SUDT standard, no metadata exists within the cells themselves. It would not make sense to include this data in every cell because this would needlessly require extra capacity. On-chain metadata should exist within a single cell that can be located easily by off-chain applications. The standard for how this metadata should be handled is not included because it is beyond the scope of this document.

Backwards Compatibility

This standard is partially backwards compatible with the original draft standard. If a new token instance was minted, with only the Instance ID present, omitting the quantity, token_logic, and custom fields, it would be functionally identical to the original draft standard. If any data exists beyond the Instance ID (32 bytes), then the constraints that apply are different.

Instance ID and Quantity Restrictions

This standard places restrictions on control over the Instance ID and Quantity values that cannot be overcome by the owner of the token class.

The SUDT standard allows the owner to mint an unlimited quantity of tokens at any time. Similarly, this standard allows the unlimited minting of new token instances at any time. However, unlike SUDT, the quantity of any token instance is fixed at the time it is minted and cannot be increased later.

In order to ensure that an Instance ID is unique, its creation uses the seed cell pattern. This effectively takes control of the Instance ID away from the owner. The owner controls when a token instance is created, but they cannot control what that Instance ID is. If quantity was left completely unrestricted, then this is effectively nullifying the aforementioned pattern. By keeping both the the Instance ID and quantity limited the standard is guaranteeing that a token instance is unique and unforgeable, even by the owner of the token class.

A real world example of why this trait is desirable would be collectable game cards such as Magic The Gathering. First editions of certain rare cards are extremely valuable to collectors. However, counterfeit prints of these rare cards exist, as do reprints by the original issuer. Restricting the Instance ID and quantity eliminates any possibility of malicious counterfeiting, and ensures that a later reprint by the issuer cannot be mistaken for the original printing.

Usage Examples

Below are some common NFT usages with examples of how it would be implemented using this standard.

Topps Garbage Pail Kids

Topps recently released the Garbage Pail Kids, trading cards popular in the 80’s, as NFTs on the WAX blockchain. These are basic trading cards without any additional functionality, which makes them very easy to implement using this standard.

The Dapp would mint a unique token instance for each unique card. No token logic or custom data would be required. The quantity field would be highly beneficial because it would allow a user to hold multiple cards of the same instance in a single cell. The associated metadata and image data would be held on Topps servers, which match against a particular Instance ID.

Multi-Token Extensible NFT - Garbage Pail Kids

CryptoKitties

A Dapp like CryptoKitties will use custom token logic script to handle breeding actions between two NFT token instances. In this case, the “DNA” of the kitties is stored in the custom data area (beyond the first 80 bytes). The custom token logic script provided by the developer will ensure that it cannot be modified directly, and can only be updated according to specific rules during a breed transaction.

A new token instance can only be created by the Dapp owner, which means that the creation of a new kitty must obtain an Instance ID from the owner if a third kitty is to be created. This can easily be accomplished by having the Dapp provide empty egg instances which must be combined with two kitties in order to breed a third kitty. Here instance ABC and DEF combine their DNA within the GHI instance. The egg instances can be minted in bulk and distributed using an on-chain script, meaning that the breeding process can be done on-chain without the need for centralization.

Another slightly different variant on the game could consume the two original kitties to produce a third. This would modify the game dynamic since it would mean a kitty would disappear after breeding, but this may be desirable if it was a game was instead CryptoSpiders, where death after breeding is expected.

In this case, instance ABC is reused, while instance DEF is consumed. The kitty “DNA” is combined in the resulting cell according to the rules provided in the token logic script.

Multi-Token Extensible NFT - CryptoKitties

Resident Evil (Guns and Ammo)

If Capcom was to release a Resident Evil with NFT based in-game items, common actions like reloading ammo can be accomplished on-chain without centralized Dapp logic.

In this case, a user owns a weapon represented by instance DEF, and owns 10 ammunition rounds represented by instance ABC. Token logic script RTY would allow the weapons ammo counter to be updated in a transaction where ammo is burned. The 10 ammo are burned in this transaction, which allows the ammo counter in the weapon to be updated from 0 to 10, reloading the weapon.

Multi-Token Extensible NFT - Resident Evil

Reference Implementation

The reference implementation is written in Rust using the Capsule Framework, and can be found on GitHub. This specification is still considered a draft and the code is not production ready .

9 Likes

Great work. This definitely pushes the boundaries on what’s possible with the extension mechanism feature.

One design principle to note (that we at Pictosis have discovered when building and testing our NFT architecture… your mileage may vary here) is that many functionalities beyond very basic extensions require additional protections and “boundaries” between the core logic and extension logic (and between extension logics in our case, though not applicable here).

It is easy to make the mistake as a developer using this design of adding in an extension that works most of the time in compatibility with the other logic… but not all of the time.

While in regular runtime systems, there needs to be a form of isolation built into various independent processes such that they can’t perform privileged operations (think about kernel vs user mode in an OS), in a verification system like CKB scripts, this protection via isolation or boundaries would be “logical”. In other words, a way of avoiding contradictions and resolving contradictions when appropriate.

I think it is also a question of ethics: to rely on developers to exhaustively test or verify that extension(s) compatibility is present in all transaction patterns when very useful - even crucial for some possible cases - extensions may expand the number of possible tx patterns that it becomes infeasible to enumerate them all (especially since a precedent has been established already in the blockchain world that additional safety measures are really important… or bad things will happen). This is especially the case in blockchain, where the vulnerabilities are not necessarily even code-caused (and therefore not even entirely amenable to costly formal verification methods) but a result of emergent patterns of interaction.

Luckily, these complicated, unpredictable circumstances can be avoided by implementing the right “local” policies, which can be pretty simple at the local level, despite the complex scenarios that they protect against.

For example of how things could go wrong, imagine that a developer wishes to add a single extension that itself loads in multiple scripts, enabling multiple extensions from this single-extension design (we could call it the “multi-extension” extension). The developer of these extensions may all be different (or not). They may have different permissions on who can update them, how they are referenced (type vs data hash), etc. This makes the situation more difficult because a developer may be completely responsible themselves in their development, but they wish to use a third-party extension, which may be the source of some critical bug. In an ideal world, all code would undergo full audits, and all third party code would be scrutinized with extreme care before a developer decides to include it. But the multitude of issues in the open source world that have arisen from this not happening shows that it’s unlikely/

Due to this, there are two options for an extensible standard to be responsible/safe for users: Strongly encourage people to only use their own extensions and to keep them very simple, and to have them exhaustively tested… or add in additional protections. I believe the latter is better for the following reasons:

  1. It encourages an ecosystem of sharing, which is good for the community, opens up new opportunities for open source contribution, and encourages frugal CKB state usage through confident code reuse (all of which are important for Nervos ecosystem and CKB itself).
  2. Whenever adding a property to a system that allows it to be used in more ways or operate in more contexts, it inherently provides more opportunities for misuse (intentional or not). So the “advising against” doing something that the technology allows to be done isn’t great. Better to just add in the right protections from the beginning.

At Pictosis, we’ve had to mitigate this issue by iteratively “tuning” the policies that govern how extensions operate in relation to each other as well as how they operate in relation to the base logic… and then checking that the behaviors that might emerge above the code-level follow our system goals.

Some of our design would not make sense for this MT-Extensible NFT standard because of differences in design goals. However, the policy that the extension mechanism in the Multi-Token Extensible NFT standard implements (borrowing again from systems world, the difference between policies and mechanisms) does not seem to have any “guard rails” or constraints against undesirable outcomes at the system level. There probably needs to be in order for community to feel confident using this design.

Here are some questions I ask when thinking about the policy of this extension mechanism:

  1. Am I able to override any base level logic? If not, is there a set of base logic that I should be able to override in some scenarios?
  2. If the custom logic fails, does the entire operation necessarily fail or are there handlers?
  3. Are there any restrictions on how an extension used for custom token logic is locked? Should there be?
  4. What possible events could occur at a higher level that would render the use of extensions dangerous (this is important, because now every extension cell is a possible vulnerability for every NFT that uses that cell)? What are the least restrictive constraints to the policy that I could add in order to mitigate the dangerous or harmful effects of these events?

Looking forward to hearing yours and others’ thoughts on this. Let’s keep this conversation going.

Tannr

5 Likes

One of the earlier revisions included the ability for token logic to override the base NFT logic. I eventually decided against this because it defeats the purpose of having a single base standard if that core logic is not guaranteed for all tokens.

I originally planned more complex pieces of logic that could be toggled on and off, but I removed that in favor of a single minimal set of core functionality that must be used by all token classes.

Overriding of base script logic is a pattern that probably has strong use cases, but I see it as being outside the scope of this standard, which is attempting to bridge the functionality gap of ERC1155.

The token logic script executes with very similar rules to a normal script. It must return an i8 error code, where 0 is success and every other value is a failure.

If any token logic script fails, the entire transaction fails. Token logic is contained in a cell dep, but its execution is nearly identical to if it was an additional input being executed in the transaction.

No, there are no restrictions on the lock script used on a token logic cell dep. I don’t think there would be any benefit to it. The token logic to be used in the NFT cell is specified by a code hash, so the security of the cell dep doesn’t matter.

The first 80 bytes in the data field are restricted to a specific use by the base NFT standard. Any data beyond that is considered the “custom” data area. The base standard does not include logic to restrict its usage in any way. This allows for complex transformations to occur to the custom data, enforced by rules in the token logic extension that are not present in the base standard.

If multiple token logic extensions were chained together, there is a composability problem since they all must share the same data area, but they do not know the offsets they should be concerned with. This could be alleviated with an umbrella token logic extension which provides loader functionality for sub-extensions, and also provides data region partitioning.

However, this composibility problem in the data with token logic extensions is not limited to this NFT standard. The composability problem exists in the greater Cell Model because of the shared data area that has no form of partitioning. The separation of concerns for a Lock Script and Type Script adds tremendous composability to the ecosystem. However, that composability is destroyed if both the Lock Script and Type Script need to store data in the cell since they cannot do so properly without being aware of how the other uses the data area.

Data region partitioning in the Cell Model is beyond the scope of this standard, but the effect on this standard does highlight its prevailing importance. As we continue to investigate more complex usage within the ecosystem, I see this as being a growing point of concern.

2 Likes

A couple things here I need to elaborate on.

If we go back to the purpose of a standard, it is to allow interacting technology to use it out of the box. By constraining what is considered valid to what is compliant, we provide predictable behavior and an unambiguous way for other technology to support anything standard compliant out of the box. Any tech that supports TCP/IP can talk with anything else that supports TCP/IP. All extensions are clearly separated at higher levels. This clean abstraction boundary provides the predictability necessary for a standard to accomplish its goal, but this boundary is broken with this design (more on this later).

The semantics of scripts in CKB is essentially a big set of conjunctions: for a tx to succeed, there is a set of protocol rules that must be met. Then there is rules of lock scripts. Then of type scripts. I.e., they can all be translated to a set of assertions about the TX, and all of them must be true.

The semantics of the extension mechanism is simply the addition of further set of assertions, and they must also succeed.

The extension’s rules exist at the same level of precedence, which is risky for multiple reasons.

First, in the case of composing various extensions: each one could be modified by the creator - an unknown third party - to cause all other dependents to fail. This is bound to happen with a system that supports this composition by providing these opportunities for developers to contribute and share code. Which is positive, but has a set of additional risks that need to be accounted for by enforcing a set of rules about extensions themselves beyond just the code the extension executes; a set of rules about how it is “packaged”. This is pretty common practice too: any good software that excepts plugins or third party additions or extensions will have rules and protections to prevent bad code from being distributed to the one who installs it.

Second, the fact that the extensions exist at the same level of precedence as the base logic undermines the interoperability conferred by the standard itself. Returning to broken boundary I mentioned in the top of this post, we can see that in e.g., TCP/IP, any two parties can talk to each other and successfully serialization and deserialize the transmitted data. Of course, protocols can be built on top of it, and if the entities are speaking different higher level protocols, the interaction may fail due to applications not quite speaking the same protocol, but the goal of TCP/IP - the transformation and transmission of the underlying data grams and frames - does not. TCP/IP isn’t responsible for making sure the two agents communicating are running the same higher level protocol.

In this token design, by contrast, base level operations succeed in some cases only if the custom logic (higher level protocol) also succeeds. So, the transaction representing a base operation can fail even though the agent (such as a wallet) creating the transaction is following the base standard perfectly.

This is analogous to TCP/IP blocking the very receipt of data if it isn’t following the same higher level protocol of the recipient. But that can’t happen because the lower protocol has no knowledge of this. This metaphor isn’t perfect but I think it captures the issue I’m getting at here.

This can be resolved in a couple ways. Either this standard doesn’t represent a transferable or burnable token (because an extension could block this basic operation, and therefore it belongs at the extension level of abstraction instead of base level) or it does, and therefore an extension cannot block that operation. Instead, there would be a well defined transaction pattern that triggers the extension execution separate from the transfer or burn operation.

In general, an extension seems to be a method by which one can add further operations rather than changing the capabilities of the built-in operations of the system in which the extension is installed. This provides a safer boundary for users, because their token can’t be rendered useless in the basic sense. An app you install on your computer shouldn’t be able to modify the kernel’s behavior. Same principle applies here.

The separation also provides better predictability of behavior for third party agents such that if they support the basic operations that the standard defines for one token, they can do it for another without changing the transaction construction logic they’ve already added for another token. Using OS as an analogy again, if different installed applications could all change A kernel’s system calls, then it wouldn’t be very effective. True, this spec supports only one extension (barring an extension that loads more extensions), so this isn’t quite perfect. A better example would be an OS that can only install a single app, but also allows you to make sys calls directly through a built in interface. If the app rewrote the syscalls, then it would mess up your ability to make use of the one other built in interface for interacting with the OS.

The only thing they’d have to change, then, is adding the custom operations, but those won’t interfere with their ability to support the basic ones. Which is good because there are some third party agents and probably on chain systems as well that wouldn’t even want to support the custom ops, only the basic ones. An example of that is an exchange.

To summarize:

  1. Well defined rules for how the extension is “packaged” on chain such that vulnerabilities are limited. This could include rules around the extension’s type and lock script for example. This becomes less of an issue, but still an issue, with the modification described in the second point below.
  2. A boundary separating the extension logic from the base logic for better safety and for preserving the minimal interoperability expected by a standard

Apologies for any weird wording or typos: I’m on my phone and it’s difficult to see the entire post at once so idk how reasonable it is structured, heh

2 Likes

There are two things worth mentioning in regard to this:

  • Code hashes are used to specify the token logic, meaning any change to the code would require an update to the code hash stored in the data field of the cell. To update it requires the express permission of both the cell owner (first-class assets) and the token class owner.
  • There is one big difference between token logic execution and normal cell logic execution that I forgot to mention. Token logic is viewed as application level and is only executed when operating in normal user mode, not owner mode. In the event that token logic is malfunctioning, it can always be replaced with the permission of both the cell owner and token class owner.

That’s a good point. Controlling the absolute behavior of token logic is not going to be possible since we have no way to sandbox execution. The only thing we can consistently control is when token logic is executed.

The standard recognizes four distinct operation modes:

  • Generate
  • Transfer
  • Update
  • Burn

In the implementation, it really boils down to Generate and Transfer/Update/Burn since the Cell Model inherently groups those operations. There may be a way to separate them. There are potential implications to higher-level operations that need to be carefully thought through.

Points well taken. In the interest of maximizing flexibility and composability, limiting the type script and lock script are not desirable. Maintaining base logic and functionality is a much stronger direction IMO.

1 Like

A really useful pattern that can emerge in CKB script development is chain-of-dependencies execution. I’m almost 100% sure this standard will be used like this if it is accepted. And if it is, that’s a powerful pattern… But becomes an anti-pattern if the way each link in the chain is secured is not enforced. It can even be enforced inductively.

Also, what I’m saying is that the validity of the “base” operation modes are changed by the token logic. So that abstraction boundary is broken. It’s not really executing in “application” versus some “privileged” mode.

But you don’t need a sandbox to accomplish this; the validity of the operations defined by the base standard have to be separated from the validity of the operations defined by the extension, or nobody is going to use extensions because of the hassle it will create for every third party agent, as well as the unpredictability it adds.

The cell model may group “Generate” in one way, and “transfer/update/burn” in another way, but I’m talking about the policies of the standard, not the implementation. Since CKB could be construed as verification machine (logical verification), the sandbox/isolation required is not “execution environment” but “logical environment”. The logical environment of a token is defined by the set of valid operations it can undergo. These have to be invariants in order for the standard to achieve predictability & therefore interoperability.

A standard defines a minimal set of valid operations. Therefore, the extension can certainly add to this set, but if it can actually remove from this base set, then it’s crossing that logical boundary and damaging the fidelity of the specification.

Maximizing flexibility and composability cannot be the only goal here. (A good simple example: refer to the “paper clip” machine in this article). If that is taken to it’s logical conclusion, then there is a slew of undesirable behaviors that would be made easier as a result of this single-pointed goal. There must always be specific goals around safety as well. So what needs to be more openly discussed is: what are a small set of constraints that can present a large amount of undesirable outcomes, while preserving the ability to create all of the desirable outcomes.

Maybe the foundation disagrees with me here. But as a community member building a highly sophisticated system related to NFTs, I would not adopt this design without modifications that put “guardrails” in place. The road between the guardrails can still be very wide, they just need to be placed so that devs aren’t accidentally swerving off the road that, unbeknownst to them, was on the edge of a cliff.

2 Likes

The only thing I’ve disagreed with in any of your posts is restricting the lock script and type script on an extension. This is a potentially future inhibitive restriction that doesn’t seem to be addressing a clear problem. Dependency recovery is already a strong point in the design of the Cell Model.

I have no issue with setting up more standardized recommendations on dependency packaging for chaining logic, but I see that as being something that should be defined in a separate standard. That standard would be directly influenced by the composability problem I mentioned earlier; of which I am actively trying to drive discussion internally.

The unfortunate answer is you cannot add any meaningful restriction without removing some potentially desirable outcomes. The debate is just around where to draw the line.

When we talk about operational modes we are inherently talking about higher-level constructs that do not exist in the Cell Model. I’m not suggesting that it isn’t beneficial or feasible. I am stating that the entire process requires thorough consideration because every additional restriction comes at the cost of flexibility and providing a rule in the standard that cannot be properly enforced in the implementation would be disastrous.

Guaranteeing the successful execution of certain common operations is undoubtedly beneficial. We’re always looking for ways to showcase the benefits of the Cell Model, and this is powerful functionality that Ethereum standards cannot provide.

Thoughts regarding this?

This exact definition does not match a large segment of standards in existence. If I used this definition (in context) I could easily argue that ERC20 and ERC1155 are not standards since they specify the interface but not the behavior of the operation.

This is less a definition of standards and more of an actual standard in itself for extending the functionality. It makes sense and it’s a powerful concept worth exploring, not just for this standard, but for any future standard.

There is an inherent incentive for developers to create code that isn’t incompatible with the systems they are trying to interface with. I can point to the existing NFT marketplaces on Ethereum as an example of this. Their NFT standards make no guarantee of compatibility, yet the marketplaces still exist and continue to grow.

Sandboxing is a more ideal solution, but it just isn’t an option. We can’t control what an extension does once it executes because it operates in the exact same context as the caller. Only when the extension is executed can be controlled.

The current implementation already utilizes a few different rules for when the extension is executed based on the detected operation. Your suggestion is to take this further by using more granular operations and adding restrictions with some of them. I was unable to come up with any scenario where an operative restriction could be put in place without impeding some kind of potentially desired behavior.

However, I believe the tradeoff is worth it since we can guarantee certain operations for this particular opt-in asset class. A transfer operation, in particular, would be the most obvious. This design feature is something that should be named for future reference.

Below is an incomplete list of the operations that exist in the current implementation. Transfer, update, and burn operations can all occur at the same time, which can make classification difficult.

Looking at what can be isolated, a Pure Transfer and Burn operation can probably be isolated without issue. A Transfer Split and Transfer Combine get trickier because of the support for a zero quantity token instance.

Generate

  • Single
  • Multiple
  • Bare (Condensed form; no quantity, token logic, or custom data.)
  • With Quantity
  • With Quantity Zero
  • With Token Logic
  • With Token Logic Null
  • With Custom Data

Transfer

  • Pure (Does not modify any field values.)
    • To Self
    • To Other
  • Split
    • Standard
    • Zero Quantity
  • Combine
    • Standard
    • Zero Quantity (Burn)
  • + Update
  • + Burn

Update

  • Change Quantity
    • Split
    • Combine
    • Burn
  • Change Token Logic (Owner mode required.)
    • Set Token Logic
    • Set Null
  • Change Custom Data (Regulated by token logic.)
  • Shapeshift (Switch between condensed and expanded quantity and token logic.)
    • Condense Quantity
    • Expand Quantity
    • Condense Token Logic
    • Expand Token Logic
    • Condense Quantity + Token Logic
    • Expand Quantity + Token Logic
  • + Transfer
  • + Burn

Burn

  • Burn
  • Burn Zero
  • + Transfer
  • + Update
1 Like

Agree to disagree.

I’m not sure which side of the trade-off you’re saying is worth it here, nor which design feature you’re referring to. Could you elaborate?

I believe it is worth the trade-off of losing some potential avenues of desirable behavior to guarantee the success of certain operations. eg: It is worth the trade-off of restricting the capability of a token logic extension to control the outcome of a transfer operation because this guarantees that transfers can occur in an expected manner.

Which operations should be guaranteed operations is still an open question. Whatever is decided on needs to be absolutely enforceable in the implementation, and I haven’t been able to dedicate enough time to thinking it through yet.

1 Like

About that,I think there would be some conversations with Kyle’s proposal.

2 Likes

I’ve updated the implementation to include guaranteed operations for transfers and burns.

Because of this change, the token logic no longer can restrict:

  • Transfers
  • Burns
  • Splits (one cell into two)
  • Combines (two cells into one)
  • Zero quantity cell creation

The ability to control the latter three could be reintroduced by adding a permission system for more specific operations. However, this is adding a lot more complexity with limited benefits. I’m unsure if it’s needed for this standard.

what does the token platform you mean here ?

Does that mean that the quantity field always exists no matter devs define it to not?

like this 00000000000000000000000000000000 ?

1 Like

It is referring to any dapp that uses NFTs. This could be something like a marketplace or auction house. It could also be something like different games that share the same underlying NFTs.

Not exactly. The Instance ID field must contain a value at all times. The quantity and Token Logic fields are optional under specific conditions. This allows for smaller capacity requirements in some cases.

  • If the quantity value is not present, then it should be assumed to be equal to a value of 1.
  • If the Token Logic value is not included, then it should be assumed to be equal to all zeros.

Yes, a Token Logic value of all zeros, (0x0000000000000000000000000000000000000000000000000000000000000000), means that there is no extra token logic to execute. Having all zeros is also the same as having no value at all, which reduces the amount of capacity needed.

1 Like

Dose that mean when the Token Logic code hash is not all zero filled, Token Logic script could be executed ?

1 Like

Yes, that’s right. When the Token Logic code has is not all zeros, it will attempt to load and execute a matching Cell Dep with that code hash.

1 Like

Got it,many thanks!

Could you give me some example about that?
Like today if a NFT instance gun could be used with a NFT bullet to kill one enemy, its script could be verified to generate some other NFT?

1 Like

I am anticipating that when Token Logic executes, most of the time the logic will only be concerned with the cells that are using the same code hash. I often refer to this as the minimal concern principle. An example of this is in the SUDT type script. When SUDT is examining the transaction, it only cares about cells that are the same SUDT. Everything else is ignored. It may end up being similar with token logic.

An example of token logic that does not use the minimal concern principle would be an NFT gun that will shoot stronger if it is paired with NFT power ups in the same transaction. In this case the token logic in the gun NFT cell needs to examine the entire transaction and check all of the cells to see which NFT power ups are present so it can calculate the total power of the gun.

The example you give where an NFT is generated by some kind of an event is possible, but there are special considerations. Since this standard has trait of guaranteed scarcity, a new NFT instance is more difficult to create from within Token Logic. An item drop describes it better. For example, an NFT sword could be used to slay the NFT dragon, which is protecting an NFT magic item that is secured by the treasure chest lock script.

1 Like