| name | ckb-ccc-udt |
| description | Covers issuing, transferring, and reading metadata for UDT / xUDT fungible tokens on CKB with the current CCC UDT API (`ccc.udt.Udt`, `@ckb-ccc/udt`). NOTE: ckb-devrel is releasing a replacement, `@ckb-ccc/coin`, within the next few weeks — once it ships, `@ckb-ccc/udt` will be removed and unmaintained, not kept for compatibility. This skill still teaches the current `@ckb-ccc/udt`-based pattern since it is the only working option until then, and this file will be replaced in place once `@ckb-ccc/coin` ships. Use when the user asks about UDT, xUDT, fungible tokens, token issuance, or token transfers on CKB — even if they just say "token" without naming UDT specifically. Builds on the standard transaction pattern in ckb-ccc-transactions. |
CKB CCC — UDT Tokens
Covers xUDT (extensible User Defined Token) issuance, transfer, and metadata reads. Assumes a connected Signer (see ckb-ccc-signer-setup) and the standard transaction pattern (see ckb-ccc-transactions) — this skill only adds the UDT-specific steps on top of that pattern.
Issuing xUDT tokens (Single-Use-Seal pattern)
xUDT issuance requires the Single-Use-Seal (SUS) pattern, which involves three sequential transactions to ensure token uniqueness and authority.
import { ccc } from "@ckb-ccc/ccc";
import { signer } from "@ckb-ccc/playground";
interface TokenMetadata {
decimals: number;
symbol: string;
name?: string;
}
type IssueStep = "seal" | "owner" | "mint";
interface IssueXudtSusParams extends TokenMetadata {
totalSupply: ccc.FixedPointLike;
onProgress?: (step: IssueStep, txHash: ccc.Hex) => void;
}
interface IssueXudtSusResult {
sealTxHash: ccc.Hex;
ownerTxHash: ccc.Hex;
mintTxHash: ccc.Hex;
typeScriptHash: ccc.Hex;
}
function encodeTokenInfo({
decimals,
symbol,
name,
}: TokenMetadata): Uint8Array {
if (!symbol) {
throw new Error("symbol is required");
}
if (!Number.isInteger(decimals) || decimals < 0 || decimals > 255) {
throw new Error("decimals must be an integer within 0-255 (1-byte field)");
}
const symbolBytes = ccc.bytesFrom(symbol, "utf8");
const nameBytes = ccc.bytesFrom(name?.trim() ? name : symbol, "utf8");
if (symbolBytes.length > 255 || nameBytes.length > 255) {
throw new Error("symbol/name must each be <= 255 bytes when UTF-8 encoded");
}
return ccc.bytesConcat(
ccc.numToBytes(decimals, 1),
ccc.numToBytes(nameBytes.length, 1),
nameBytes,
ccc.numToBytes(symbolBytes.length, 1),
symbolBytes,
);
}
class XudtSusIssuer {
constructor(private readonly signer: ccc.Signer) {}
async issue(params: IssueXudtSusParams): Promise<IssueXudtSusResult> {
const { decimals, totalSupply } = params;
if (!Number.isInteger(decimals) || decimals < 0 || decimals > 255) {
throw new Error(
"decimals must be an integer within 0-255 (1-byte field)",
);
}
const tokenInfo = encodeTokenInfo(params);
const supply = ccc.fixedPointFrom(totalSupply, decimals);
if (supply < 0n || supply >= 1n << 128n) {
throw new Error("totalSupply must encode to a non-negative uint128");
}
const { script: ownerLock } = await this.signer.getRecommendedAddressObj();
const sealTxHash = await this.createSealCell(ownerLock);
params.onProgress?.("seal", sealTxHash);
const singleUseLock = await ccc.Script.fromKnownScript(
this.signer.client,
ccc.KnownScript.SingleUseLock,
ccc.OutPoint.from({ txHash: sealTxHash, index: 0 }).toBytes(),
);
const ownerTxHash = await this.createOwnerCell(singleUseLock);
params.onProgress?.("owner", ownerTxHash);
const { mintTxHash, typeScriptHash } = await this.mintToken({
sealTxHash,
ownerTxHash,
singleUseLock,
ownerLock,
supply,
tokenInfo,
});
params.onProgress?.("mint", mintTxHash);
return { sealTxHash, ownerTxHash, mintTxHash, typeScriptHash };
}
private async createSealCell(lock: ccc.Script): Promise<ccc.Hex> {
const tx = ccc.Transaction.from({ outputs: [{ lock }] });
await tx.completeInputsByCapacity(this.signer);
await tx.completeFeeBy(this.signer);
const txHash = await this.signer.sendTransaction(tx);
await this.signer.client.cache.markUnusable({ txHash, index: 0 });
return txHash;
}
private async createOwnerCell(singleUseLock: ccc.Script): Promise<ccc.Hex> {
const tx = ccc.Transaction.from({ outputs: [{ lock: singleUseLock }] });
await tx.completeInputsByCapacity(this.signer);
await tx.completeFeeBy(this.signer);
return this.signer.sendTransaction(tx);
}
private async mintToken(args: {
sealTxHash: ccc.Hex;
ownerTxHash: ccc.Hex;
singleUseLock: ccc.Script;
ownerLock: ccc.Script;
supply: bigint;
tokenInfo: Uint8Array;
}): Promise<{ mintTxHash: ccc.Hex; typeScriptHash: ccc.Hex }> {
const {
sealTxHash,
ownerTxHash,
singleUseLock,
ownerLock,
supply,
tokenInfo,
} = args;
const xudtType = await ccc.Script.fromKnownScript(
this.signer.client,
ccc.KnownScript.XUdt,
singleUseLock.hash(),
);
const uniqueType = await ccc.Script.fromKnownScript(
this.signer.client,
ccc.KnownScript.UniqueType,
"00".repeat(32),
);
const tx = ccc.Transaction.from({
inputs: [
{ previousOutput: { txHash: sealTxHash, index: 0 } },
{ previousOutput: { txHash: ownerTxHash, index: 0 } },
],
outputs: [
{ lock: ownerLock, type: xudtType },
{ lock: ownerLock, type: uniqueType },
],
outputsData: [ccc.numLeToBytes(supply, 16), tokenInfo],
});
await tx.addCellDepsOfKnownScripts(
this.signer.client,
ccc.KnownScript.SingleUseLock,
ccc.KnownScript.XUdt,
ccc.KnownScript.UniqueType,
);
await tx.completeInputsByCapacity(this.signer);
if (!tx.outputs[1].type) {
throw new Error("UniqueType output unexpectedly missing before TypeId patch");
}
tx.outputs[1].type.args = ccc.hexFrom(
ccc.bytesFrom(ccc.hashTypeId(tx.inputs[0], 1)).slice(0, 20),
);
await tx.completeFeeBy(this.signer);
const mintTxHash = await this.signer.sendTransaction(tx);
await this.signer.client.waitTransaction(mintTxHash);
return { mintTxHash, typeScriptHash: tx.outputs[0].type!.hash() };
}
}
export async function issueXudtWithSUS(
params: IssueXudtSusParams,
): Promise<IssueXudtSusResult> {
return new XudtSusIssuer(signer).issue(params);
}
const result = await issueXudtWithSUS({
decimals: 8,
symbol: "ZXMOTO",
totalSupply: 100_000_000,
onProgress: (step, txHash) => console.log(step, txHash),
});
console.log(result);
Transferring UDT tokens
- Construct UDT instance — Resolve type script:
await ccc.Script.fromKnownScript(client, ccc.KnownScript.XUdt, args). Get code OutPoint from cell deps. Create: new ccc.udt.Udt(code, typeScript).
- Build transfer —
const { res: tx } = await udt.transfer(signer, [{ to: lock, amount: 100n }]).
- Complete UDT inputs —
tx = await udt.completeBy(tx, signer) — adds UDT inputs and change output. Do not skip: omitting this loses tokens permanently.
- Complete CKB capacity —
await tx.completeInputsByCapacity(signer).
- Pay fee and send —
await tx.completeFeeBy(signer), then await signer.sendTransaction(tx).
- Read metadata (SSRI tokens only) —
udt.name(), udt.symbol(), udt.decimals(), udt.icon(). Always check return value is not undefined — legacy sUDT tokens do not implement SSRI.
async function transferUdt(signer: ccc.Signer, receiverAddress: string) {
const { script: recipientLock } = await ccc.Address.fromString(receiverAddress, signer.client);
const type = await ccc.Script.fromKnownScript(
signer.client, ccc.KnownScript.XUdt, "0x<xudt-cell-typescript-args>"
);
const code = (await signer.client.getCellDeps(
(await signer.client.getKnownScript(ccc.KnownScript.XUdt)).cellDeps
))[0].outPoint;
const udt = new ccc.udt.Udt(code, type);
const decimals = 8;
let { res: tx } = await udt.transfer(signer, [{ to: recipientLock, amount: ccc.fixedPointFrom(100, decimals)}]);
tx = await udt.completeBy(tx, signer);
await tx.completeInputsByCapacity(signer);
await tx.completeFeeBy(signer);
const txHash = await signer.sendTransaction(tx);
return txHash;
}
Rule: UDT transfers need completeBy (UDT) then completeInputsByCapacity (CKB) — order matters.
Gotchas (UDT-specific)
| Symptom / Error | Cause | Fix |
|---|
| UDT tokens lost after transfer | udt.completeBy() not called | Always call udt.completeBy(tx, signer) before completeInputsByCapacity; it adds the token change output |
udt.name() / symbol() returns undefined | Token doesn't implement SSRI | Only xUDT with SSRI support returns metadata; always guard with ?? "unknown" |
| On-chain amount is 10^decimals smaller than expected | Used human-facing amount directly instead of scaling by decimals | Always use ccc.fixedPointFrom(humanFacingAmount, decimals) for both mint and transfer — the on-chain integer is display * 10^decimals, not the human-facing number |
Q&A
Q: How do I find the args for a specific xUDT?
A: You can view it in the CKB explorer:
- Find the xUDT in the CKB explorer (e.g., https://testnet.explorer.nervos.org/xudt/0xb5378a3a22ed158233a4493ec0994483aada2bc6de9446952184653d90bdf889)
- Navigate to the xUDT's detail page
- In the info section, look for the Type Script section and find the Args field
- This Args value is the xUDT's args, used when constructing
ccc.Script.fromKnownScript(client, ccc.KnownScript.XUdt, args)
Checklist (UDT-specific)
Also check the checklists in ckb-ccc-fundamentals and ckb-ccc-transactions.