Skip to main content

Tutorial: Simple UDT Script

Tutorial Overview

⏰ Estimated Time: 5 - 7 min
πŸ’‘ Topics: UDT, Script, Transaction
πŸ”§ Tools You Need:
  • offckb, version >= v0.3.0-rc2
  • git,make,sed,bash,sha256sum and others Unix utilities.
  • Rust and riscv64 target: rustup target add riscv64imac-unknown-none-elf
  • Clang 16+
    • Debian / Ubuntu: wget https://apt.llvm.org/llvm.sh && chmod +x llvm.sh && sudo ./llvm.sh 16 && rm llvm.sh
    • Fedora 39+: sudo dnf -y install clang
    • Archlinux: sudo pacman --noconfirm -Syu clang
    • MacOS (with Homebrew): brew install llvm@16
    • Windows (with Scoop): scoop install llvm yasm

User-Defined Token (UDT) is a fungible token standard on CKB blockchain.

In this tutorial, we will create a UDT Script, a simplified version of XUDT standard to help you better understand how fungible tokens work on CKB.

It is highly recommended to go through the dApp tutorial Create a Fungible Token first before writing your own UDT Script.

The full code of the UDT Script in this tutorial can be found at Github.

Data Structure for simple UDT Cell​

data:
<amount: uint128>
type:
code_hash: UDT type script hash
args: <owner lock script hash>
lock: <user_defined>

Here the issuer's Lock Script Hash works like the unique ID for the custom token. Different Lock Script Hashes correspond to different types of token issued by different owners. This hash is also used to determine if a transaction is initiated by the token issuer or a regular token holder, to apply different security checks.

  • Token Owner: Can perform any operation.
  • Regular Holders: The UDT Script ensures that the amount in the output Cells does not exceed that in the input Cells. For a more detailed explanation, please refer to Create a Fungible Token or RFC0025: Simple UDT

Now, let's create a new project to build the UDT Script. We will use OFFCKB and ckb-script-templates.

Initialize a Script Project​

Let's run the command to generate a new Script project called sudt-script(short for simple UDT):

offckb create --script sudt-script

Create a New Script​

Let’s create a new Script called sudt inside the project.

cd sudt-script
make generate

Our project is successfully setup. You can run tree . to view the project structure:

tree .

contracts/sudt/src/main.rs contains the source code of the sUDT Script, and tests/tests.rs provides the unit tests for our Scripts. We will introduce the tests after completing the Script.

Implement sUDT Script Logic​

The sUDT Script is implemented in contracts/sudt/src/main.rs. This script is designed to manage the transfer of UDTs on the CKB blockchain.

Let's break down the high-level logic of how this Script works:

  1. Owner Mode Check: The Script first checks if it is being executed in owner mode. This is important because running in owner mode means that the owner has special permissions, allowing the Script to return success immediately without further checks.

  2. Input and Output Amount Validation: Next, the Script collects the total UDTs amounts from the input and output Cells to ensure that the UDTs being sent (outputs) do not exceed that being received (inputs). This is crucial for maintaining the integrity of token transfers.

Here’s the code snippet illustrating these checks:

pub fn program_entry() -> i8 {
ckb_std::debug!("This is a sample UDT contract!");

let script = load_script().unwrap();
let args: Bytes = script.args().unpack();
ckb_std::debug!("script args is {:?}", args);

// Check if the script is in owner mode
if check_owner_mode(&args) {
return 0; // Success if in owner mode
}

// Collect amounts from input and output cells
let inputs_amount: u128 = match collect_inputs_amount() {
Ok(amount) => amount,
Err(err) => return err as i8,
};
let outputs_amount = match collect_outputs_amount() {
Ok(amount) => amount,
Err(err) => return err as i8,
};

// Validate that inputs are greater than or equal to outputs
if inputs_amount < outputs_amount {
return Error::InvalidAmount as i8; // Error if invalid amount
}

0 // Success
}
  1. Error Handling: The Script includes robust error handling through a custom Error enum to manage various error conditions, such as invalid input or encoding issues. This ensures clear communication of any problems.

Together, this implementation validates UDT transactions effectively, maintaining the integrity of token transfers on the CKB blockchain. By checking both the owner mode and the amounts, the Script helps preventing errors and promotes smooth operation.

Collecting UDT Amount​

In the sUDT Script, the collect_inputs_amount and collect_outputs_amount functions play a crucial role in gathering the total amounts of UDTs from the respective input and output cells.

Here’s how they work:

Both functions iterate through the relevant Cells and collect the cell_data to calculate the total UDT amount, done with the following syscalls:

  • Source::GroupInput
  • Source::GroupOutput

For source group input and output respectively, they ensure that only the Cells with the same Script as the current running Script are involved in the iteration.

By using these specific sources, the Script avoids issues related to different UDT Cell types, ensuring that only the appropriate Cells can be processed. This is particularly important in a blockchain environment where multiple UDTs may exist, to ensure accurate iteration of the relevant UDT Script.

Here’s a brief look at how these functions are implemented:

fn collect_inputs_amount() -> Result<u128, Error> {
let mut buf = [0u8; UDT_AMOUNT_LEN];

let udt_list = QueryIter::new(load_cell_data, Source::GroupInput)
.map(|data| {
if data.len() >= UDT_AMOUNT_LEN {
buf.copy_from_slice(&data);
Ok(u128::from_le_bytes(buf))
} else {
Err(Error::AmountEncoding)
}
})
.collect::<Result<Vec<_>, Error>>()?;
Ok(udt_list.into_iter().sum::<u128>())
}

fn collect_outputs_amount() -> Result<u128, Error> {
let mut buf = [0u8; UDT_AMOUNT_LEN];

let udt_list = QueryIter::new(load_cell_data, Source::GroupOutput)
.map(|data| {
if data.len() >= UDT_AMOUNT_LEN {
buf.copy_from_slice(&data);
Ok(u128::from_le_bytes(buf))
} else {
Err(Error::AmountEncoding)
}
})
.collect::<Result<Vec<_>, Error>>()?;
Ok(udt_list.into_iter().sum::<u128>())
}

In summary, these functions are essential for the accurate calculation of the total UDT amounts involved in the transaction, while ensuring that only the relevant Cells are considered, thus maintaining the integrity of the UDT transfer process.

Full code: contracts/sudt/src/main.rs

Write Tests for UDT Script​

Testing is a crucial part of developing any smart contract, including the sUDT Script. The test_transfer_sudt test case is designed to simulate a transfer of UDTs from one address to another. It sets up the necessary environment, including creating input and output Cells, then invoking the sUDT Script to perform the transfer. The test checks whether the transfer transaction is successfully verified.

Here’s the code for the transfer_sudt test case:

#[test]
fn test_transfer_sudt() {
let mut context = Context::default();

// build lock script
let always_success_out_point = context.deploy_cell(ALWAYS_SUCCESS.clone());
let lock_script = context
.build_script(&always_success_out_point, Default::default())
.expect("script");
let lock_script_dep = CellDep::new_builder()
.out_point(always_success_out_point)
.build();

// prepare scripts
let contract_bin: Bytes = Loader::default().load_binary("sudt");
let out_point = context.deploy_cell(contract_bin);
let type_script = context
.build_script(&out_point, Bytes::from(vec![42]))
.expect("script");
let type_script_dep = CellDep::new_builder().out_point(out_point).build();

let input_token: u128 = 400;
let output_token1: u128 = 300;
let output_token2: u128 = 100;

// prepare cells
let input_out_point = context.create_cell(
CellOutput::new_builder()
.capacity(1000u64.pack())
.lock(lock_script.clone())
.type_(Some(type_script.clone()).pack())
.build(),
input_token.to_le_bytes().to_vec().into(),
);
let input = CellInput::new_builder()
.previous_output(input_out_point)
.build();
let outputs = vec![
CellOutput::new_builder()
.capacity(500u64.pack())
.lock(lock_script.clone())
.type_(Some(type_script.clone()).pack())
.build(),
CellOutput::new_builder()
.capacity(500u64.pack())
.lock(lock_script)
.type_(Some(type_script).pack())
.build(),
];

let outputs_data = vec![
Bytes::from(output_token1.to_le_bytes().to_vec()),
Bytes::from(output_token2.to_le_bytes().to_vec()),
];

// build transaction
let tx = TransactionBuilder::default()
.input(input)
.outputs(outputs)
.outputs_data(outputs_data.pack())
.cell_dep(lock_script_dep)
.cell_dep(type_script_dep)
.build();
let tx = context.complete_tx(tx);

// run
let cycles = context
.verify_tx(&tx, 10_000_000)
.expect("pass verification");
println!("consume cycles: {}", cycles);
}

In this test case:

  • We deploy the sUDT Script we wrote and use it to build the Cell's Type Script.
  • We set up the initial conditions by defining the valid input and output amounts.
  • Finally, we build the full transaction and ensure its validity.

Notice that in the Type Script args we use a random bytes which is 42 indicating that we are not under the owner_mode in the test_transfer_sudt test case.

For owner mode test case, refer to the full tests code for reference.

By writing tests like test_transfer_sudt, we can ensure that our sUDT Script functions correctly under various scenarios, helping to identity any issues before deployment.


Congratulations!​

By following this tutorial so far, you have mastered how to write a simple UDT Script that brings fungible tokens to CKB. Here's a quick recap:

  • Use offckb and ckb-script-templates to init a Script project
  • Use ckb_std to leverage CKB syscalls Source::GroupIuput/Source::GroupOutput for performing relevant Cell iteration.
  • Write unit tests to make sure the UDT Script works as expected.

Additional Resources​