Asset Tokenization
Asset tokenization refers to the process of representing real-world assets, such as real estate, art, commodities, stocks, or other valuable assets, as digital tokens on the blockchain network. This involves converting the ownership or rights of an asset into digital tokens, which are then recorded and managed on the blockchain.
High-level overview
The concept is to divide high-value assets into smaller, more affordable units, representing ownership or a fraction of the asset.
This strategy enables wider participation from investors who might want to mitigate risk by investing in a portion of a digital asset rather than being the sole owner, thereby expanding accessibility to a broader range of investors.
This pattern is similar to the ERC1155 multi-token standard with additional functionality. This makes it a suitable choice for Solidity based use cases that one might want to implement on IOTA.
-
Asset creation
Each asset is fractionalized into a total supply, with each fraction represented as either a non-fungible token (NFT) or fungible token (FT) type collectible. This ensures that each individual fraction maintains a balance equal to or greater than one, and when combined, all fractions collectively reach the total supply of the asset.
Besides the total supply, each asset is defined by various other fields such as name, description, and more. These fields collectively form the metadata for the asset, and they remain consistent across all fractions of the asset.
-
NFTs vs FTs distinction
Each time a tokenized asset is minted, there's a possibility for it to be created with new metadata. If new metadata is incorporated, the tokenized asset is deemed unique, transforming it into an NFT. In this case, its balance is limited to one, signifying that only a single instance of this asset exists.
If there's no additional metadata, the tokenized asset is categorized as an FT, allowing its balance to exceed one, enabling multiple identical instances of the asset to exist.
FTs possess the capability to merge (join) among themselves or be split when the balance is greater than one. This functionality allows for the aggregation or division of units of the token, offering flexibility in handling varying quantities as needed.
As previously mentioned, all the collectibles of tokenized assets, whether NFTs or FTs, when combined, can amount to the maximum total supply of the asset.
-
Burnability
When you create the asset, you can define whether the fractions of the asset are eligible for removal or destruction from circulation. The process of removing or destroying assets is called burning.
If a tokenized asset is burnable, then burning a fraction causes the circulating supply to decrease by the balance of the burnt item. The total supply, however, remains constant, allowing you to mint the burned fractions again if needed, thus maintaining the predetermined total supply of the asset.
Move packages
As with all smart contracts on Iota, Move provides the logic that powers asset tokenization.
asset_tokenization package
The asset_tokenization reference implementation uses the Kiosk standard to ensure that tokenized assets operate within their defined policy. Use the implementation as presented to have marketable tokenized assets that support rules like royalties, commissions, and so on.
If using Kiosk is not a requirement, then you can exclude the unlock module and some of the proxy's methods related to transfer policies.
Select a module to view its details:
- tokenized_asset
- proxy
- unlock
The tokenized_asset
module operates in a manner similar to the coin
library.
When it receives a new one-time witness type, it creates a unique representation of a fractional asset. This module employs similar implementations to some methods found in the Coin
module. It encompasses functionalities pertinent to asset tokenization, including new asset creation, minting, splitting, joining, and burning.
Structs
AssetCap
Generate an AssetCap
for each new asset represented as a fractional NFT. In most scenarios, you should create it as an owned object, which you can then transfer to the platform's administrator for access-restricted method invocation.
public struct AssetCap<phantom T> {
id: UID,
// the current supply in circulation
supply: Supply<T>,
// the total max supply allowed to exist at any time
total_supply: u64,
// Determines if the asset can be burned or not
burnable: bool
}
AssetMetadata
The AssetMetadata
struct defines the metadata representing the entire asset to fractionalize. This should be a shared object.
public struct AssetMetadata<phantom T> has key, store {
id: UID,
/// Name of the asset
name: String,
// the total max supply allowed to exist at any time
total_supply: u64,
/// Symbol for the asset
symbol: ascii::String,
/// Description of the asset
description: String,
/// URL for the asset logo
icon_url: Option<Url>
}
TokenizedAsset
The TokenizedAsset
is minted with a specified balance that is less than or equal to the remaining supply. If the VecMap
of an asset is populated with values, indicating multiple unique entries, it is considered an NFT. Conversely, if the VecMap
of an asset is not populated, indicating an absence of individual entries, it is considered an FT.
public struct TokenizedAsset<phantom T> has key, store {
id: UID,
/// The balance of the tokenized asset
balance: Balance<T>,
/// If the VecMap is populated, it is considered an NFT, else the asset is considered an FT.
metadata: VecMap<String, String>,
/// URL for the asset image (optional)
image_url: Option<Url>,
}
PlatformCap
The PlatformCap
refers to the capability issued to the individual who deploys the contract. This capability grants specific permissions or authority related to the platform's functionalities, allowing the deployer certain controlled actions or access rights within the deployed contract.
/// Capability that is issued to the one deploying the contract
public struct PlatformCap has key, store { id: UID }
Functions
init
This function creates a PlatformCap
and sends it to the sender.
fun init(ctx: &mut TxContext) {}
new_asset
This function holds the responsibility of creating a fresh representation of an asset, defining its crucial attributes. Upon execution, it returns two distinct objects: the AssetCap
and AssetMetadata
. These objects encapsulate the necessary information and characteristics defining the asset within the system.
public fun new_asset<T: drop>(
witness: T,
total_supply: u64,
symbol: ascii::String,
name: String,
description: String,
icon_url: Option<Url>,
burnable: bool,
ctx: &mut TxContext
): (AssetCap<T>, AssetMetadata<T>) {}
mint
The function performs the minting of a tokenized asset. If new metadata is introduced during this process, the resulting tokenized asset is considered unique, resulting in the creation of an NFT with a balance set to 1. Alternatively, if no new metadata is added, the tokenized asset is classified as an FT, permitting its balance to surpass 1, as specified by a provided argument. Upon execution, the function returns the tokenized asset object.
public fun mint<T>(
cap: &mut AssetCap<T>,
keys: vector<String>,
values: vector<String>,
value: u64,
ctx: &mut TxContext
): TokenizedAsset<T> {}
split
This function is provided with a tokenized asset of the FT type and a balance greater than 1, along with a value less than the object's balance, and performs a split operation on the tokenized asset. The operation divides the existing tokenized asset into two separate tokenized assets. The newly created tokenized asset has a balance equal to the given value, while the balance of the provided object is reduced by the specified value. Upon completion, the function returns the newly created tokenized asset. This function does not accept or operate on tokenized assets of the NFT type.
public fun split<T>(
self: &mut TokenizedAsset<T>,
split_amount: u64,
ctx: &mut TxContext
): TokenizedAsset<T> {}
join
This function is given two tokenized assets of the FT type and executes a merge operation on the tokenized assets. The operation involves increasing the balance of the first tokenized asset by the balance of the second one. Subsequently, the second tokenized asset is burned or removed from circulation. After the process concludes, the function returns the ID of the burned tokenized asset.
This function does not accept or operate on tokenized assets of the NFT type.
public fun join<T>(
self: &mut TokenizedAsset<T>,
other: TokenizedAsset<T>
): ID {}
burn
This function requires the assetCap
as a parameter, thereby restricting its invocation solely to the platform admin. Additionally, it accepts a tokenized asset that is burned as part of its operation. Upon burning the provided tokenized asset, the circulating supply decreases by the balance of the burnt item. It necessitates a tokenized asset that is burnable.
public fun burn<T>(
cap: &mut AssetCap<T>,
tokenized_asset: TokenizedAsset<T>
)
total_supply
This function retrieves and returns the value representing the total supply of the asset.
public fun total_supply<T>(cap: &AssetCap<T>): u64 {}
supply
This function retrieves and returns the value representing the current circulating supply of the asset.
public fun supply<T>(cap: &AssetCap<T>): u64 {}
value
This function takes a tokenized asset as input and retrieves its associated balance value.
public fun value<T>(tokenized_asset: &TokenizedAsset<T>): u64 {}
create_vec_map_from_arrays
This internal helper function populates a VecMap<String, String>
. It assists in the process of filling or setting key-value pairs within the VecMap
data structure.
fun create_vec_map_from_arrays(
keys: vector<String>,
values: vector<String>
): VecMap<String, String> {}
The proxy
module comprises methods that the type owner utilizes to execute publisher-related operations.
Structs
Proxy
The PROXY
struct represents the one-time witness (OTW) to claim the publisher.
public struct PROXY has drop {}
Registry
This shared object serves as a repository for the Publisher
object, specifically intended to control and restrict access to the creation and management of transfer policies for tokenized assets. Mutable access to this object is exclusively granted to the actual publisher.
public struct Registry has key {
id: UID,
publisher: Publisher
}
ProtectedTP
This is a shared object that stores an empty transfer policy. It is required to create one per type <T>
generated by a user. Its involvement is apparent in the unlock module.
public struct ProtectedTP<phantom T> has key, store {
id: UID,
policy_cap: TransferPolicyCap<T>,
transfer_policy: TransferPolicy<T>
}
Functions
init
This function is responsible for creating the Publisher
object, encapsulating it within the registry, and subsequently sharing the Registry
object.
fun init(otw: PROXY, ctx: &mut TxContext) {}
setup_tp
This function leverages the publisher nested within the registry and the sender's publisher. It generates and returns a transfer policy and the associated transfer policy cap specific to the TokenizedAsset<T>
. This type 'T' is derived from the Publisher
object.
It also generates an empty transfer policy wrapped in a ProtectedTP<T>
object, which is shared. You can use this functionality under specific conditions to override the kiosk lock rule.
public fun setup_tp<T: drop>(
registry: &Registry,
publisher: &Publisher,
ctx: &mut TxContext
): (TransferPolicy<TokenizedAsset<T>>,
TransferPolicyCap<TokenizedAsset<T>>) {}
new_display
This function utilizes the publisher nested within the registry and the sender's publisher to generate and return an empty Display
for the type TokenizedAsset<T>
, where T
is encapsulated within the Publisher
object.
public fun new_display<T: drop>(
registry: &Registry,
publisher: &Publisher,
ctx: &mut TxContext
): Display<TokenizedAsset<T>> {}
transfer_policy
This function, provided with the protectedTP
, returns the transfer policy specifically designed for the type TokenizedAsset<T>
public(friend) fun transfer_policy<T>(
protected_tp: &ProtectedTP<T>
): &TransferPolicy<T> {}
publisher_mut
This function can only be accessed by the owner of the platform cap. It requires the registry as an argument to obtain a mutable reference to the publisher.
public fun publisher_mut(
_: &PlatformCap,
registry: &mut Registry
): &mut Publisher {}
The unlock
module facilitates the unlocking of a tokenized asset specifically for authorized burning and joining.
It allows tokenized asset type creators to enable these operations for kiosk assets without necessitating adherence to the default set of requirements, such as rules or policies.
Structs
JoinPromise
A promise object is established to prevent attempts of permanently unlocking an object beyond the intended scope of joining.
public struct JoinPromise {
/// the item where the balance of the burnt tokenized asset will be added.
item: ID,
/// burned is the id of the tokenized asset that will be burned
burned: ID,
/// the expected final balance of the item after merging
expected_balance: u64
}
BurnPromise
A promise object created to ensure the permanent burning of a specified object.
public struct BurnPromise {
expected_supply: u64
}
Functions
asset_from_kiosk_to_join
This helper function is intended to facilitate the joining of tokenized assets locked in kiosk. It aids in unlocking the tokenized asset that is set for burning and ensures that another tokenized asset of the same type will eventually contain its balance by returning a JoinPromise.
public fun asset_from_kiosk_to_join<T>(
self: &TokenizedAsset<T>, // A
to_burn: &TokenizedAsset<T>, // B
protected_tp: &ProtectedTP<TokenizedAsset<T>>, // unlocker
transfer_request: TransferRequest<TokenizedAsset<T>> // transfer request for b
): JoinPromise {}
prove_join
A function utilized to demonstrate that the unlocked tokenized asset is successfully burned and its balance is incorporated into an existing tokenized asset.
public fun prove_join<T>(
self: &TokenizedAsset<T>,
promise: JoinPromise,
proof: ID) {
}
asset_from_kiosk_to_burn
Helper function that facilitates the burning of tokenized assets locked in a kiosk. It assists in their unlocking while ensuring a promise that the circulating supply will be reduced, achieved by returning a BurnPromise
.
public fun asset_from_kiosk_to_burn<T>(
to_burn: &TokenizedAsset<T>,
asset_cap: &AssetCap<T>,
protected_tp: &ProtectedTP<TokenizedAsset<T>>,
transfer_request: TransferRequest<TokenizedAsset<T>>,
): BurnPromise {
}
prove_burn
Ensures that the circulating supply of the asset cap is reduced by the balance of the burned tokenized asset.
public fun prove_burn<T>(
asset_cap: &AssetCap<T>,
promise: BurnPromise) {
}
template package
The template package allows for seamless asset creation.
It leverages the above explained asset_tokenization
package.
To represent a new asset as a fractional asset, modify this module to <template>::<TEMPLATE>
, with the <template>
(in capitals) being the OTW of this new asset.
This module calls the asset_tokenization::tokenized_asset::new_asset(...)
method, which facilitates the declaration of new fields for the asset:
witness
: The OTWNEW_ASSET
total_supply
: The total supply allowed to exist at any timesymbol
: The symbol for the assetname
: The name of the assetdescription
: The description of the asseticon_url
: The URL for the asset logo (optional)burnable
: Boolean that defines if the asset can be burned by an admin
The template package also contains a genesis type of module that includes a OTW so that the sender can claim the publisher.
Publish and mint tokenized sequence diagram
Join sequence diagram
The following sequence diagram presenting how the join flow would take place. The following flow assumes that:
- Tokenized assets X and Y have already been minted by the creator of their type.
- Tokenized assets X and Y are already placed and locked inside the user's kiosk.
- Everything is executed in the same programmable transaction block (PTB).
Burn sequence diagram
The following sequence diagram shows the burn flow and assumes that:
- Tokenized asset has already been minted by the creator of its type.
- Tokenized asset is already placed and locked inside the user's Kiosk.
- Everything is executed in the same PTB.
Variations
The packages and modules provided demonstrate how you could implement asset tokenization for your project. Your particular use case probably necessitates altering the contract for convenience or to introduce new features.
Example convenience alteration
Instead of implementing the unlock functionality in multiple steps inside of a PTB, it would also be possible to create a method that performs the purchase, borrowing, unlocking and joining of an asset all on one function. This is how that would look like for the joining operation:
public fun kiosk_join<T>(
kiosk: &mut Kiosk,
kiosk_cap: &KioskOwnerCap,
protected_tp: &ProtectedTP<TokenizedAsset<T>>,
ta1_id: ID,
ta2_id: ID,
ctx: &mut TxContext
) {
kiosk::list<TokenizedAsset<T>>(kiosk, kiosk_cap, ta2_id, 0);
let (ta1, promise_ta1) = kiosk::borrow_val(kiosk, kiosk_cap, ta1_id);
let coin = coin::zero<IOTA>(ctx);
let (ta2, request) = kiosk::purchase(kiosk, ta2_id, coin);
let tp_ref = proxy::transfer_policy(protected_tp);
let (_item, _paid, _from) = transfer_policy::confirm_request(
tp_ref,
request
);
tokenized_asset::join(&mut ta1, ta2);
kiosk::return_val(kiosk, ta1, promise_ta1);
}
Example alteration for use case
The following example splits (effectively replacing) the AssetCap<T>
into two new objects: the Treasury<T>
and the AdminCap<T>
. The access to methods defined in the original package, should now be carefully re-designed as this change can introduce unwanted effects. This required re-design is not entirely contained in this example and only some methods are changed for demonstration purposes (or as a thorough exercise).
Assume you want to allow the users to also burn assets, not only admins. This still needs to be an authorized operation but it would allow the flexibility of consuming tokenized assets for a use case specific purpose (for example, burning all of the collectibles you've gathered to combine them). To achieve this, the admin can mint tickets that contain the ID of the asset they are allowed to burn. To support this functionality you must redesign the smart contract and separate the admin from the asset's treasury of each asset, which now holds only supply related information. Sample changes that need to happen follow:
Structs
Create a ticket that has only the key
ability so that the receiver cannot trade it.
public struct BurnTicket<phantom T> has key {
id: UID,
tokenized_asset_id: ID // the tokenized asset that this ticket gives access to burn
}
The struct that now only holds treasury related information (results from splitting the AssetCap
, meaning it's no longer part of this design) is created as a shared object. Change functions like mint
to also take as input both the Treasury
object and the AdminCap
object.
public struct Treasury<phantom T> has key, store {
id: UID,
supply: Supply<T>,
total_supply: u64,
}
The other half of the AssetCap
functionality which retains the admin capability and the configuration of burnability is an owned object sent to the creator of type <T>
.
public struct AdminCap<phantom T> has key, store {
id: UID,
burnable: bool
}
Method Signatures
The AdminCap
here acts both as an admin capability and a type insurance. Encoding the information of both the asset type that is allowed to be deleted with this ticket.
This function should assert that the asset T is burnable and return a BurnTicket<T>
.
public fun mint_burn_ticket<T>(
cap: &AdminCap<T>,
tokenized_asset_id: ID,
ctx: &mut TxContext
): BurnTicket
Burning on the user side requires for them to access the shared Treasury
object. This function burns the tokenized asset and decreases the supply.
public fun burn_with_ticket<T>(
treasury: &mut Treasury<T>,
self: TokenizedAsset<T>,
ticket: BurnTicket<T>
)