Skip to main content

Create a Fungible Token

Tutorial Overview

⏰ Estimated Time: 5 - 10 min
💡 Topics: UDT, Fungible Token, xUDT
🔧 Tools You Need:

Custom Token on CKB

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

In CKB, custom tokens are called User-Defined-Token, aka UDT. The core team of CKB has proposed a minimal standard for UDT called xUDT(extensible UDT). In this tutorial, we will use the pre-deployed smart contracts xUDT script to issue custom tokens.

The high-level workflow to issue a custom token with xUDT goes like this:

When you issue a token, you create a special Cell that presents some balance of your token, like a piece of printed cash to the dollars.

For this special Cell, its data field contains the amount of the token and its type script is xUDT script where the args of that Script will be the issuer's lock script hash.

This issuer's lock script hash works like the unique ID for the custom token. Different lock script Hash means a different kind of token. It is also used as a checkpoint to tell that a transaction is triggered by the token issuer or a regular token holder to apply different security validation.

In reality, xUDT is more complicated and powerful with many features but the idea is the same, you can check the full specs here.

Setup Devnet & Run Example

Step 1: Clone the Repository

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

git clone https://github.com/nervosnetwork/docs.nervos.org.git
cd docs.nervos.org/examples/xudt

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

You might want to check pre-funded accounts and copy private keys for later use. Open another terminal and execute:

offckb accounts

Step 3: Run the Example

Navigate to your project, install the node dependencies, and start running the example:

yarn && NETWORK=devnet yarn start

Now, the app is running in http://localhost:1234


Behind the Scene

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 xudtDeps = lumosConfig.SCRIPTS.XUDT;

const { lockScript } = generateAccountFromPrivateKey(privKey);
const xudtArgs = utils.computeScriptHash(lockScript) + '00000000';

const typeScript = {
codeHash: xudtDeps.CODE_HASH,
hashType: xudtDeps.HASH_TYPE,
args: xudtArgs,
};
...
}

This function accepts two parameters:

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

Note that we aim to create an output Cell whose type script is an xUDT script. The args of this xUDT script are the issuer's lock script hash, which is why we include the following lines of code:

const { lockScript } = generateAccountFromPrivateKey(privKey);
const xudtArgs = utils.computeScriptHash(lockScript) + "00000000";

Also, note that the 00000000 here is just a placeholder. To unlock more capabilities of the xUDT script, this placeholder can contain specific data. However, we don't need to concern ourselves with this detail at the moment.

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

const targetOutput: Cell = {
cellOutput: {
capacity: "0x0",
lock: lockScript,
type: typeScript,
},
data: bytes.hexify(number.Uint128LE.pack(amount)),
};

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

Next, to complete our issueToken function, we just use the helpers.TransactionSkeleton to build the transaction with our desired output Cells.

let txSkeleton = helpers.TransactionSkeleton();
txSkeleton = addCellDep(txSkeleton, {
outPoint: {
txHash: lockDeps.TX_HASH,
index: lockDeps.INDEX,
},
depType: lockDeps.DEP_TYPE,
});
...
txSkeleton = txSkeleton.update('inputs', (inputs) => inputs.push(...collected));
txSkeleton = txSkeleton.update('outputs', (outputs) => outputs.push(targetOutput, changeOutput));

...

Lastly, we do the signing and witness data part, just like what we mentioned in the previous tutorial in the Transfer CKB example:

// prepare witness data
/* 65-byte zeros in hex */
const lockWitness =
"0x0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000";
const outputTypeWitness = xudtWitnessType.pack({ extension_data: [] });
const witnessArgs = blockchain.WitnessArgs.pack({
lock: lockWitness,
outputType: outputTypeWitness,
});
const witness = bytes.hexify(witnessArgs);
txSkeleton = txSkeleton.update("witnesses", (witnesses) =>
witnesses.set(0, witness)
);

// signing
txSkeleton = commons.common.prepareSigningEntries(txSkeleton);
const message = txSkeleton.get("signingEntries").get(0)?.message;
const Sig = hd.key.signRecoverable(message!, privKey);
const tx = helpers.sealTransaction(txSkeleton, [Sig]);

// submit transaction
const hash = await rpc.sendTransaction(tx, "passthrough");
console.log("The transaction hash is", hash);

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: HexString) {
const xudtDeps = lumosConfig.SCRIPTS.XUDT;
const typeScript = {
codeHash: xudtDeps.CODE_HASH,
hashType: xudtDeps.HASH_TYPE,
args: xudtArgs,
};

const collected: Cell[] = [];
const collector = indexer.collector({ type: typeScript });
for await (const cell of collector.collect()) {
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 indexer.collector({ type: typeScript }); 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.

let txSkeleton = helpers.TransactionSkeleton();
txSkeleton = addCellDep(txSkeleton, {
outPoint: {
txHash: lockDeps.TX_HASH,
index: lockDeps.INDEX,
},
depType: lockDeps.DEP_TYPE,
});
txSkeleton = addCellDep(txSkeleton, {
outPoint: {
txHash: xudtDeps.TX_HASH,
index: xudtDeps.INDEX,
},
depType: xudtDeps.DEP_TYPE,
});

const targetOutput: Cell = {
cellOutput: {
capacity: "0x0",
lock: receiverLockScript,
type: typeScript,
},
data: bytes.hexify(number.Uint128LE.pack(amount)),
};

const capacity = helpers.minimalCellCapacity(targetOutput);
targetOutput.cellOutput.capacity = "0x" + capacity.toString(16);

You may notice that the transferTokenToAddress function is pretty long, while the core transfer logic above is quite simple. The problem is that we need to handle the capacity change in the changeOutputCell. If the change capacity is less than 61CKB, we need to add another Live Cell in our inputs to build the changeOutputCell. Also, we need to handle the changes in the token amount. If there is any token amount remaining, we need to return the change amount along with change capacities to the sender.

let changeOutputTokenAmount = BI.from(0);
if (collectedAmount.gt(BI.from(amount))) {
changeOutputTokenAmount = collectedAmount.sub(BI.from(amount));
}

const changeOutput: Cell = {
cellOutput: {
capacity: "0x0",
lock: senderLockScript,
type: typeScript,
},
data: bytes.hexify(
number.Uint128LE.pack(changeOutputTokenAmount.toString(10))
),
};

const changeOutputNeededCapacity = BI.from(
helpers.minimalCellCapacity(changeOutput)
);

const extraNeededCapacity = collectedSum.lt(neededCapacity)
? neededCapacity.sub(collectedSum).add(changeOutputNeededCapacity)
: collectedSum.sub(neededCapacity).add(changeOutputNeededCapacity);

if (extraNeededCapacity.gt(0)) {
let extraCollectedSum = BI.from(0);
const extraCollectedCells: Cell[] = [];
const collector = indexer.collector({
lock: senderLockScript,
type: "empty",
});
for await (const cell of collector.collect()) {
extraCollectedSum = extraCollectedSum.add(cell.cellOutput.capacity);
extraCollectedCells.push(cell);
if (extraCollectedSum >= extraNeededCapacity) break;
}

if (extraCollectedSum.lt(extraNeededCapacity)) {
throw new Error(
`Not enough CKB for change, ${extraCollectedSum} < ${extraNeededCapacity}`
);
}

txSkeleton = txSkeleton.update("inputs", (inputs) =>
inputs.push(...extraCollectedCells)
);

const change2Capacity = extraCollectedSum.sub(changeOutputNeededCapacity);
if (change2Capacity.gt(61000000000)) {
changeOutput.cellOutput.capacity = changeOutputNeededCapacity.toHexString();
const changeOutput2: Cell = {
cellOutput: {
capacity: change2Capacity.toHexString(),
lock: senderLockScript,
},
data: "0x",
};
txSkeleton = txSkeleton.update("outputs", (outputs) =>
outputs.push(changeOutput2)
);
} else {
changeOutput.cellOutput.capacity = extraCollectedSum.toHexString();
}
}

All the extra logic here can be a little confusing at first time. However, the overall high-level process is quite simple and straightforward. We are also looking forward to some tools like Lumos to automatically cover such works in the future.


Congratulations!

By following this tutorial this far, 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 Cell by passing the lock script hash of the token issuer
  • Transfer tokens to another account by replacing the lock script.

Next Step

So now your dApp works great on the local blockchain, you might want to switch it to different environments like Testnet and Mainnet.

To do that, just change the environment variable NETWORK to testnet:

export NETWORK=testnet

For more details, check out the README.md.

Additional Resources