Skip to main content

Create a Fungible Token

⏰ Estimated Time: 10 - 15 min after setup
🔧 What You Will Need:

Tutorial Overview​

Unlike ERC20(Ethereum) and BRC20(Bitcoin), CKB uses a unique way to build custom tokens based on its UTXO-like Cell Model.

In CKB, custom tokens are called User-Defined Tokens (UDTs). CKB defines a minimal standard for UDTs called xUDT(extensible UDT). In this tutorial, you will learn how to issue custom tokens using the pre-deployed xUDT Script.

Steps to Issue a Custom Token with xUDT:

  1. Create a Special Cell: When you issue tokens, you create a special Cell representing a balance of your custom token, similar to how physical cash represents a balance of currency.
  2. Configure the Cell's Data: This Cell’s data field will store the token amount, while its Type Script will be the xUDT Script. In this example, the xUDT Script’s args field contains the issuer’s 32-byte Lock Script hash followed by the four-byte flags field 00000000.
  3. Establish a Unique Token ID: In this example, the issuer’s Lock Script hash identifies the custom token. Different issuer Lock Script hashes represent different tokens.

While xUDT includes more advanced features, this tutorial focuses on its core concept. For more details on xUDT’s capabilities, you can explore the full xUDT spec.

Setup Devnet & Run Example​

Step 1: Download the Source Code​

To get started with the tutorial dApp, clone the repository and navigate to the appropriate directory:

git clone https://github.com/nervosnetwork/docs.nervos.org.git --depth 1
cd docs.nervos.org/examples/dApp/xudt

You can also read the full code online or download it here.

Step 2: Start the Devnet​

To interact with the dApp, ensure that your Devnet is up and running. After installing @offckb/cli, open a terminal and start the Devnet with the following command:

offckb node

Keep the Devnet running throughout the tutorial. Open another terminal to list the pre-funded accounts:

offckb accounts

Private keys are hidden by default. If the tutorial asks you to copy a Devnet private key, append --show-private-keys:

offckb accounts --show-private-keys

Use plain offckb accounts for everyday account checks. These are public development keys for the local OffCKB Devnet; never use them for Mainnet or real assets. See OffCKB's account documentation.

Step 3: Run the Example​

Navigate to your project folder (docs.nervos.org/examples/dApp/xudt), install the dependencies, and start running the example:

npm install
npm start

Now, the app is running at http://localhost:1234.

Issue, Query, and Transfer​

The Private Key field in Issue Custom Token is prefilled with the first pre-funded OffCKB Devnet account’s private key. This is a publicly known development key included in the example code, not read from your wallet. Keep it for this local tutorial or replace it with another Devnet account’s private key. Never use this development key for Mainnet or real assets.

  1. In Issue Custom Token, enter a token amount and click Issue Token.
  2. Copy the complete Token xUDT args from the result into View Custom Token, not the args field inside the Lock Script JSON displayed in Issue Custom Token. Wait for the transaction to be committed, then click Query Issued Token.
  3. To transfer, use the same token args, the current token holder's private key, an amount they own, and another Devnet account's address. The sender also needs enough CKB capacity for the output Cells and transaction fee.

Behind the Scenes​

Issuing Custom Token​

Open the lib.ts file in your project and check out the issueToken function:

export async function issueToken(privKey: string, amount: string) {
const signer = new ccc.SignerCkbPrivateKey(cccClient, privKey);
const lockScript = (await signer.getAddressObjSecp256k1()).script;
const xudtArgs = lockScript.hash() + "00000000";

const typeScript = await ccc.Script.fromKnownScript(
signer.client,
ccc.KnownScript.XUdt,
xudtArgs
);
...
}

This function accepts two parameters:

  • privKey: The private key of the issuer
  • amount: The amount of tokens

Note that we aim to create an output Cell whose Type Script is an xUDT Script. The args of this xUDT Script contain the issuer's Lock Script hash followed by the flags field, which is why we include the following lines of code:

const lockScript = (await signer.getAddressObjSecp256k1()).script;
const xudtArgs = lockScript.hash() + "00000000";

The 00000000 suffix is the four-byte xUDT flags field set to zero. This field tells the xUDT Script which validation options to use. All zeros mean that no extension Scripts are configured, and the standard input Lock Script check for owner authorization remains enabled. More advanced configurations are beyond this tutorial’s scope. Keep this suffix when copying the token args for queries and transfers.

Further down in the function, you'll see that the complete target output Cell of our custom token appears as follows:

const tx = ccc.Transaction.from({
outputs: [{ lock: lockScript, type: typeScript }],
outputsData: [ccc.numLeToBytes(amount, 16)],
});

Note that the outputsData field is the amount of the custom token.

Next, add the xUDT Script dependency and use CCC to collect the CKB inputs needed for the output Cells and transaction fee.

  await tx.addCellDepsOfKnownScripts(signer.client, ccc.KnownScript.XUdt);
// Complete missing parts for transaction
await tx.completeInputsByCapacity(signer);
await tx.completeFeeBy(signer, 1000);

...

Lastly, we do the signing and sending of the transaction:

const txHash = await signer.sendTransaction(tx);
console.log("The transaction hash is", txHash);

Token Info & Holders​

Since we have issued a custom token, the next step will be checking out this token and viewing its holders. To do that, we write a queryIssuedTokenCells in the lib.ts file:

export async function queryIssuedTokenCells(xudtArgs: ccc.Hex) {
const typeScript = await ccc.Script.fromKnownScript(
cccClient,
ccc.KnownScript.XUdt,
xudtArgs
);

const collected: ccc.Cell[] = [];
const collector = cccClient.findCellsByType(typeScript, true);
for await (const cell of collector) {
collected.push(cell);
}
return collected;
}

Note that to query a custom token Cell, we must know its xUDTArgs. As explained in the high-level ideas for xUDT Scripts, this xUDTArgs functions like the unique ID for the token you issued.

Thus, queryIssuedTokenCells will accept only one parameter: xudtArgs. We then construct a Type Script with this xudtArgs and use cccClient.findCellsByType(typeScript, true); to query the Live Cells that possess such a Type Script.

By identifying the Lock Scripts of these Live Cells, we can determine that those custom tokens now belong to the individual who can unlock this Lock Script. Consequently, we know who the token holders are.

Transfer Custom Token​

The next step you want to do is probably sending your tokens to someone else. To do that, you will replace the Lock Script of the custom token Cell with the receiver's Lock Script. Therefore, the receiver can unlock the custom token Cell. In this way, the token is transferred from you to other people.

Check out the transferTokenToAddress function in lib.ts file.

export async function transferTokenToAddress(
udtIssuerArgs: string,
senderPrivKey: string,
amount: string,
receiverAddress: string,
){
...
}

The function use udtIssuerArgs to build the Type Script from the custom token. It then collects Live Cells which match the Type Script and the Lock Script of the senderLockScript, effectively saying, "give me the custom token Cells that belong to the sender (the sender can unlock the Lock Script).".

With all these Live Cells, we can build the transaction to produce custom token Cells with the required amount and the receiver's Lock Scripts from the input Cells.

const signer = new ccc.SignerCkbPrivateKey(cccClient, senderPrivKey);
const senderLockScript = (await signer.getAddressObjSecp256k1()).script;
const receiverLockScript = (
await ccc.Address.fromString(receiverAddress, cccClient)
).script;

const xudtArgs = udtIssuerArgs;
const xUdtType = await ccc.Script.fromKnownScript(
cccClient,
ccc.KnownScript.XUdt,
xudtArgs
);

const tx = ccc.Transaction.from({
outputs: [{ lock: receiverLockScript, type: xUdtType }],
outputsData: [ccc.numLeToBytes(amount, 16)],
});
await tx.completeInputsByUdt(signer, xUdtType);

Notice that if there is any token amount remaining, we need to return the change amount along with change capacities to the sender.

const balanceDiff =
(await tx.getInputsUdtBalance(signer.client, xUdtType)) -
tx.getOutputsUdtBalance(xUdtType);
console.log("balanceDiff: ", balanceDiff);
if (balanceDiff > ccc.Zero) {
tx.addOutput(
{
lock: senderLockScript,
type: xUdtType,
},
ccc.numLeToBytes(balanceDiff, 16)
);
}
await tx.addCellDepsOfKnownScripts(signer.client, ccc.KnownScript.XUdt);

// Complete missing parts for transaction
await tx.completeInputsByCapacity(signer);
await tx.completeFeeBy(signer, 1000);

const txHash = await signer.sendTransaction(tx);

Congratulations!​

After following this tutorial, you have mastered how custom tokens work on CKB. Here's a quick recap:

  • Create a CKB transaction containing a xUDT Cell in the outputs
  • The data of the xUDT Cell contains the amount number of the token
  • Query the custom token Cells using the complete xUDT args returned by the app
  • Transfer tokens to another account by replacing the Lock Script.

Next Step​

To try the example on Testnet, stop the app with Ctrl+C, then run:

npm run start:testnet

Use a separate Testnet-only account funded with test CKB. Devnet balances and tokens do not exist on Testnet, so issue a new token there. Return to the local tutorial by stopping the app and running npm start with NETWORK unset.

Additional Resources​