Create a Fungible Token
Tutorial Overview
- An IDE/Editor that supports TypeScript
- CKB dev environment: OffCKB
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 Script 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:
- Command
- Response
offckb node
/bin/sh: /Users/nervosDocs/.nvm/versions/node/v18.12.1/lib/node_modules/@offckb/cli/target/ckb/ckb: No such file or directory
/Users/nervosDocs/.nvm/versions/node/v18.12.1/lib/node_modules/@offckb/cli/target/ckb/ckb not found, download and install the new version 0.113.1..
CKB installed successfully.
init Devnet config folder: /Users/nervosDocs/.nvm/versions/node/v18.12.1/lib/node_modules/@offckb/cli/target/devnet
modified /Users/nervosDocs/.nvm/versions/node/v18.12.1/lib/node_modules/@offckb/cli/target/devnet/ckb-miner.toml
CKB output: 2024-03-20 07:56:44.765 +00:00 main INFO sentry sentry is disabled
CKB output: 2024-03-20 07:56:44.766 +00:00 main INFO ckb_bin::helper raise_fd_limit newly-increased limit: 61440
CKB output: 2024-03-20 07:56:44.854 +00:00 main INFO ckb_bin::subcommand::run ckb version: 0.113.1 (95ad24b 2024-01-31)
CKB output: 2024-03-20 07:56:45.320 +00:00 main INFO ckb_db_migration Init database version 20230206163640
CKB output: 2024-03-20 07:56:45.329 +00:00 main INFO ckb_launcher Touch chain spec hash: Byte32(0x3036c73473a371f3aa61c588c38924a93fb8513e481fa7c8d884fc4cf5fd368a)
You might want to check pre-funded accounts and copy private keys for later use. Open another terminal and execute:
- Command
- Response
offckb accounts
Print account list, each account is funded with 42_000_000_00000000 capacity in the genesis block.
[
{
privkey: '0x6109170b275a09ad54877b82f7d9930f88cab5717d484fb4741ae9d1dd078cd6',
pubkey: '0x02025fa7b61b2365aa459807b84df065f1949d58c0ae590ff22dd2595157bffefa',
lockScript: {
codeHash: '0x9bd7e06f3ecf4be0f2fcd2188b23f1b9fcc88e5d4b65a8637b17723bbda3cce8',
hashType: 'type',
args: '0x8e42b1999f265a0078503c4acec4d5e134534297'
},
address: 'ckt1qzda0cr08m85hc8jlnfp3zer7xulejywt49kt2rr0vthywaa50xwsqvwg2cen8extgq8s5puft8vf40px3f599cytcyd8',
args: '0x8e42b1999f265a0078503c4acec4d5e134534297'
},
{
privkey: '0x9f315d5a9618a39fdc487c7a67a8581d40b045bd7a42d83648ca80ef3b2cb4a1',
pubkey: '0x026efa0579f09cc7c1129b78544f70098c90b2ab155c10746316f945829c034a2d',
lockScript: {
codeHash: '0x9bd7e06f3ecf4be0f2fcd2188b23f1b9fcc88e5d4b65a8637b17723bbda3cce8',
hashType: 'type',
args: '0x758d311c8483e0602dfad7b69d9053e3f917457d'
},
address: 'ckt1qzda0cr08m85hc8jlnfp3zer7xulejywt49kt2rr0vthywaa50xwsqt435c3epyrupszm7khk6weq5lrlyt52lg48ucew',
args: '0x758d311c8483e0602dfad7b69d9053e3f917457d'
},
#...
]
Step 3: Run the Example
Navigate to your project, install the node dependencies, and start running the example:
- Command
- Response
yarn && NETWORK=devnet yarn start
$ parcel index.html
Server running at http://localhost:1234
✨ Built in 66ms
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 issueramount
: The amount of token
Note that we aim to create an output Cell whose Type Script
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
- xUDT specs: RFC-0052-extensible-udt
- sUDT specs: RFC-0025-simple-udt
- CKB transaction structure: RFC-0022-transaction-structure
- CKB data structure basics: RFC-0019-data-structure