Compact m-NFT uses Sparse Merkle Tree (SMT) to reduce the on-chain storage cost of NFT issuance. It compresses multiple m-NFTs into one single cell for individual user. Token transfer happens in an asynchronous way to avoid the Anyone-can-pay (ACP) requirement for the receiver’s lock as well as the cell competition problems.
Basic Ideas
There are generally two schemes to store data into SMT for users, User-shared SMT (USS) and Token-shared SMT (TSS). USS uses a single cell for different users, so it requires aggregator to combine users’ transactions. TSS needs a seperated cell for individual user, and it manages all kinds of tokens for the specific user. Compact UDT protocol adopts the USS mode, it’s more space efficient, also more complicated on implementation. As a comparison, TSS mode is simple yet powerful.
USS vs TSS design scheme comparision
The reason why we choose TSS
To be honest, TSS has several drawbacks.
- It requires a seperated storage space for every user, which costs much more than USS, and it’s not friendly to ask user to create the cell.
- We have to face the cross cell operation problem, and figure out a solution simalar to cheque cell and anyone-can-pay.
However, TSS has some unique advantages that lead us to make the final decision.
- It would be super difficult to handle various user locks in the USS typescript. For example, Unipass L3 uses SMT to store the user name to address map. USS has to design a perfect protocol to make the 3rd-part lockscripts to follow the data and witness structure. There will also be lots of potential security vulnerabilities.
- The implementation of USS aggregator is a big challenge. You need to open an RPC for all uses, because of all the users need to operate the USS cell, it’s almost impossible to operate by multiplue hosts, it must be some centralized service to avoid the cell collision.
- TSS’s lockscript and typescript is decoupled. You don’t care about the lockscript logics when implement the TSS script.
- TSS does not require a heavy-load centralized aggregator, because everyone handles their own SMT in seperated cell.
- TSS is incentive compatible for the token transfer, since everyone spends their own money to pay the tx fee. On the contrary, USS model spends some public pool to pay the fee.
Two-step transfer
Since everyone has an SMT cell for the NFT safe box, there is a naturally question that how to transfer NFTs from one box to another? We notice that cheque cell and anyone-can-pay solution were introduced to resolve the same question for SUDT payment. But cheque cell adds an extral cell for payment, which makes it more expensive, and acp forces the receiver’s lockscript to adopt acp logics and coupled with SUDT typescript.
Here we introduce a new method to handle this problem. The principle is simple, the sender first minus his/her token and leave a proof in his/her own cell, and then the receiver restore the token in his/her own cell according to the proof. With this method, the sender has no needs to operate the receiver’s cell like that required in acp mode, and the receiver could immediately confirm the token when he/she scans the block to get the proof. More over, there is no Intermediate cell needed.
To acheive it, we must follow two rules. Rule number 1, there is only one account cell for one specific cell to ensure only one deposit proof could be claimed once. Rule number 2, the account cell cannot be destroyed or transfered, or the receiver may not claim their NFTs. So there has to be a global account cell registry to ensure the uniqueness.
User experience
For the user side, it would remain almost the same as mNFT usages. The sender needs not wait for the receiver’s account cell ready, because the sender doesn’t modify the receiver’s state. The receiver needn’t claim the incoming tokens until he/she sends out them.
Data Structure
To support compact NFT (cNFT), we have to make the following data structure upgrade for previous mNFT protocol.
- Introduce a new type of cell
compact_nft_cell
to hold cNFT for users - Add
nft_smt_root
field in NFT Class cell for directly cNFT minting - Update
version
from0x00 ⇒ 0x01
in NFT Class Cell and NFT Cell to keep compatibility
Global account registry
The main purpose of global account registry is to make sure every lockscript maintains only one cell.
# global_compact_registry_cell data structure
data:
version: byte
registry_smt_root: bytes32
type:
code_hash: global_compact_registry_type
args: type_id # type_id == lock_hash[0..20]
lock:
always_success lock
Compact NFT Cell / Account Cell
compact_nft_cell
is used to store user’s token states, including those the user holds, sent, and claimed. To keep the cell unique and untransferable, its typescript.args
must match its lockscript.
# compact_nft_cell data structure
data:
version: byte # must be 0
nft_smt_root: byte32
type:
code_hash: compact_nft_type
args: lockscript_hash[0..20] # must match self.lockscript
lock:
user-defined
cNFT SMT data structure
The nft_smt_root
is the most important data field in the design. It appears in both nft_class_cell
and compact_nft_cell
. Data stored in the SMT is managed by key-value pair. There are mainly three kinds of data stored and compressed in this hash root. We use smt.key.smt_type
to identify different data types.
owned_nft only presents in the compact_nft_cell
, it maintains the token list that the current user holds. The smt_type
is set to 0x01
.
# owned_nft data structure
key:
hash of {
smt_type: uint8 # 0x01
issuer_id: Byte[20]
class_id: uint32
token_idx: uint32
}
value:
hash of {
characteristic: Byte[8]
configure: Byte[1]
state: Byte[1]
}
claimed_nft only presents in the compact_nft_cell
too. It is used to detect the replay attack to make sure the cell claim deposit only once. Here we use the deposit outpoint hash to identify different deposits. The smt_type
is set to 0x02
.
# claimed_nft data structure
key:
hash of {
smt_type: uint8 # 0x02
out_point: OutPoint[12..36] // Outpoint field recorded in the proof
}
value:
0x00...00 for nonclaimed
0xFF...FF for claimed
withdrawal_nft exists in both nft_class_cell
and compact_nft_cell
. It’s used to generate a deposit proof for the receiver to claim. The smt_type
is set to 0x03
.
# withdrawal_nft data structure
key:
hash of {
smt_type: uint8 # 0x03
issuer_id: Byte[20]
class_id: uint32
token_idx: uint32
}
value:
hash of {
characteristic: Byte[8]
configure: Byte[1]
state: Byte[1]
to: lockscript_hash[0..20]
out_point: OutPoint[12..36] # Outpoint of previous input cell with SMT
}
General Token Extension in the Future
Now we have a general SMT state framework for end users. We could also propose an user friendly fungible token protocol based on the same framework. For example, we may use owned_ft, claimed_ft, and withdrawal_ft k-v pair in the smt_root
field to process both fungible and non-fungible tokens.
# owned_ft data structure
key:
hash of {
smt_type: uint8 # 0x11
issuer_id: Byte[20]
class_id: uint32
token_idx: uint32
}
value:
hash of {
amount: uint128
}
# claimed_ft data structure
key:
hash of {
smt_type: uint8 # 0x12
out_point: OutPoint[12..36] // Outpoint field recorded in the proof
}
value:
0x00...00 for nonclaimed
0xFF...FF for claimed
# withdrawal_ft data structure
key:
hash of {
smt_type: uint8 # 0x13
issuer_id: Byte[20]
class_id: uint32
token_idx: uint32
}
value:
hash of {
amount: uint128
to: lockscript_hash[0..20]
out_point: OutPoint[12..36] // Outpoint of previous input cell with SMT
}