UDT

Learn how to issue, transfer, and manage xUDT tokens using the RGB++ SDK through practical examples.

Prerequisites

Installation
Primary SDK (Recommended):
npm i ckb-ccc@0.0.0-canary-20250710073207
Testnet Access
Testnet3 API:https://api.testnet.rgbpp.io
Signet API:https://api.signet.rgbpp.io
Mainnet Configuration
Mainnet API:https://api.rgbpp.io
Access:
Restricted to whitelisted users Please contact us at buidl@rgbpp.com.

UDT Token Operations

In CKB, custom tokens are implemented as User-Defined Tokens (UDTs). The CKB core team has established a minimal standard for UDTs called xUDT (extensible UDT). In this section, we demonstrate the RGB++ protocol by issuing a RGB++ token using the pre-deployed xUDT Script.

For a comprehensive guide on issuing fungible tokens on CKB, please refer to the tutorial: Create a Fungible Token. The following discussion will focus on the RGB++-specific aspects of the token issuance process.

The complete implementation is available in the RGB++ SDK repository.

1. Issuance

The process begins by selecting a UTXO (or automatically creating one with the dust limit value of 546 satoshis if not provided) to serve as the initial single-use seal.

Subsequently, we create a CKB cell with its lock script set to the RGB++ lock script, using the UTXO as its argument. This configuration represents the user's intent to issue a RGB++ xUDT token, which will only be fulfilled after the initial UTXO is spent.

Next, a partial CKB transaction is constructed using the CKB cell and xUDT script information. Based on this, the commitment is calculated and the BTC transaction is assembled, which is then submitted to the network.

The BTC transaction's confirmation status is periodically checked through the SPV service. Upon confirmation, we acquire the new single-use seal - a UTXO also with the dust limit value of 546 satoshis - which represents ownership of the issued RGB++ xUDT token. The transaction ID of this UTXO is used to replace the placeholder value in the RGB++ lock script, enabling the assembly of the final CKB transaction.

The final CKB transaction is then submitted to the network, completing the token issuance process.

The following is the code for the issuance process.

Code Example: Token Issuance

async function issueUdt({
  udtScriptInfo,
  utxoSeal,
}: {
  udtScriptInfo: ScriptInfo;
  utxoSeal?: UtxoSeal;
}) {
  // Initialize the RGB++ environment
  const {
    rgbppBtcWallet, 
    rgbppUdtClient, 
    utxoBasedAccountAddress, 
    ckbRgbppUnlockSinger,
  } = initializeRgbppEnv();

  // Prepare the initial single-use seal and corresponding RGB++ cells
  if (!utxoSeal) {
    utxoSeal = await rgbppBtcWallet.prepareUtxoSeal();
  }
  const rgbppIssuanceCells = await prepareRgbppCells(utxoSeal, rgbppUdtClient);

  // Construct the partial CKB transaction
  const ckbPartialTx = await rgbppUdtClient.issuanceCkbPartialTx({
    token: udtToken,
    amount: issuanceAmount,
    rgbppLiveCells: rgbppIssuanceCells,
    udtScriptInfo,
  });

  // Build and submit the BTC transaction
  const { psbt, indexedCkbPartialTx } = await rgbppBtcWallet.buildPsbt({
    ckbPartialTx,
    ckbClient,
    rgbppUdtClient,
    btcChangeAddress: utxoBasedAccountAddress,
    receiverBtcAddresses: [utxoBasedAccountAddress],
  });
  const btcTxId = await rgbppBtcWallet.signAndSendTx(psbt);
  
  const ckbPartialTxInjected = await rgbppUdtClient.injectTxIdToRgbppCkbTx(
    indexedCkbPartialTx,
    btcTxId,
  );
  // Polling the SPV service to wait for the BTC transaction to be confirmed to construct the witness
  const rgbppSignedCkbTx = await ckbRgbppUnlockSinger.signTransaction(ckbPartialTxInjected);
  
  // Build and submit the final CKB transaction
  await rgbppSignedCkbTx.completeFeeBy(ckbSigner);
  const ckbFinalTx = await ckbSigner.signTransaction(rgbppSignedCkbTx);
  const txHash = await ckbSigner.client.sendTransaction(ckbFinalTx);
  await ckbRgbppUnlockSinger.client.waitTransaction(txHash);  
}

issueUdt({
  udtScriptInfo: {
    name: ccc.KnownScript.XUdt,
    script: await ccc.Script.fromKnownScript(
      ckbClient,
      ccc.KnownScript.XUdt,
      "",
    ),
    cellDep: (await ckbClient.getKnownScript(ccc.KnownScript.XUdt)).cellDeps[0]
      .cellDep,
  },
});

2. Transfer on BTC

The process of transferring RGB++ xUDT tokens on BTC follows a similar pattern to the issuance process. Key points to note:

  • The unique ID of issued xUDT token, obtained during issuance, is used to construct the xUDT script that identifies the token.
  • The partial CKB transaction assembly is simplified through ccc: by providing the xUDT script, ccc automatically handles the input construction.

The following is the code for the transfer process.

Code Example: Token Transfer on BTC

async function transferUdt({
  udtScriptInfo,
  receivers,
}: {
  udtScriptInfo: ScriptInfo;
  receivers: RgbppBtcReceiver[];
}) {
  // ...
                
  const udt = new ccc.udt.Udt(
    udtScriptInfo.cellDep.outPoint,
    udtScriptInfo.script,
  );
  // Complete the outputs
  let { res: tx } = await udt.transfer(
    ckbSigner as unknown as ccc.Signer,
    receivers.map((receiver) => ({
      to: rgbppUdtClient.buildPseudoRgbppLockScript(),
      amount: ccc.fixedPointFrom(receiver.amount),
    })),
  );
  // Auto complete the xUDT inputs
  const txWithInputs = await udt.completeChangeToLock(
    tx,
    ckbRgbppUnlockSinger,
    rgbppUdtClient.buildPseudoRgbppLockScript(),
  );

  // the rest is the same as the issuance process...
}

transferUdt({
  udtScriptInfo: {
    name: ccc.KnownScript.XUdt,
    script: await ccc.Script.fromKnownScript(
      ckbClient,
      ccc.KnownScript.XUdt,
      "<unique id of issued xUDT token>",
    ),
    cellDep: (await ckbClient.getKnownScript(ccc.KnownScript.XUdt)).cellDeps[0]
      .cellDep,
  },
  receivers: [
    {
      amount: "<amount of xUDT token to transfer>",
      to: "<receiver's BTC address>",
    },
  ],
});

3. Leap to CKB

The process of leaping xUDT from BTC to CKB follows the same pattern as regular transfer, with one key distinction: after the leap, the lock script changes from RGBPP_Lock to BTC_TIME_lock.

The following is the code for the leap process.

Code Example: Leap to CKB

async function btcUdtToCkb({
  udtScriptInfo,
  receivers,
}: {
  udtScriptInfo: ScriptInfo;
  receivers: { address: string; amount: bigint }[];
}) {
  // ...

  const udt = new ccc.udt.Udt(
    udtScriptInfo.cellDep.outPoint,
    udtScriptInfo.script,
  );

  let { res: tx } = await udt.transfer(
    ckbSigner as unknown as ccc.Signer,
    await Promise.all(
      receivers.map(async (receiver) => ({
        // build the BTC_TIME_lock script
        to: await rgbppUdtClient.buildBtcTimeLockScript(receiver.address),
        amount: ccc.fixedPointFrom(receiver.amount),
      })),
    ),
  );

  // the rest is the same as the transfer process...
}

btcUdtToCkb({
    udtScriptInfo: {
        name: ccc.KnownScript.XUdt,
        script: await ccc.Script.fromKnownScript(
        ckbClient,
        ccc.KnownScript.XUdt,
        "<unique id of issued xUDT token>",
        ),
        cellDep: (await ckbClient.getKnownScript(ccc.KnownScript.XUdt)).cellDeps[0]
        .cellDep,
    },
    receivers: [
    {
      address: "<receiver's CKB address>",
      amount: "<amount of xUDT token to leap>",
    },
  ],
});

4. Unlocking BTC_TIME_lock

This process is relatively straightforward. We wait for the required number of confirmations (default is 6) before unlocking the BTC_TIME_lock. After unlocking, the xUDT becomes a standard CKB asset, with its ownership logic governed by the lock script specified in the BTC_TIME_lock arguments. Notably, this process does not require any Bitcoin transaction.

The following is the code for the unlock process.

Code Example: Unlock BTC_TIME_lock

async function unlockBtcTimeLock(btcTimeLockArgs: string) {
  // ...

  const tx = ccc.Transaction.default();
  const btcTimeLockCells = await collectBtcTimeLockCells(
    btcTimeLockArgs,
    rgbppUdtClient,
  );
  // Complete the inputs and outputs
  btcTimeLockCells.forEach((cell) => {
    const cellInput = ccc.CellInput.from({
      previousOutput: cell.outPoint,
    });
    cellInput.completeExtraInfos(ckbClient);
    tx.inputs.push(cellInput);
    tx.addOutput(
      {
        lock: parseBtcTimeLockArgs(cell.cellOutput.lock.args).lock,
        type: cell.cellOutput.type,
        capacity: cell.cellOutput.capacity,
      },
      cell.outputData,
    );
  });

  // Monitor the SPV service until the Bitcoin transaction achieves the required confirmation threshold for witness construction
  for await (const btcTimeLockCell of btcTimeLockCells) {
    const { btcTxId, confirmations } = parseBtcTimeLockArgs(
      btcTimeLockCell.cellOutput.lock.args,
    );
    const spvProof = await pollForSpvProof(
      rgbppBtcWallet,
      btcTxId,
      confirmations,
    );
    tx.cellDeps.push(
      ccc.CellDep.from({
        outPoint: spvProof.spvClientOutpoint,
        depType: "code",
      }),
    );
    tx.witnesses.push(buildBtcTimeUnlockWitness(spvProof.proof));
  }

  // Build and submit the final CKB transaction
  await tx.completeFeeBy(ckbSigner);
  const signedTx = await ckbSigner.signTransaction(tx);
  const txHash = await ckbSigner.client.sendTransaction(signedTx);
  await ckbSigner.client.waitTransaction(txHash);
}

unlockBtcTimeLock(
  "<btc time lock args>",
)