Brand New CKB Rust SDK, A Clean and Flexible Way of Generating CKB Transaction

Some Background

From the very beginning the Rust crate ckb-sdk used as a sub-crate of ckb-cli, it’s inconvenient for user to find the source code, and also will lead to some misunderstandings. So we make it a standalone repository and refactor the code for easy generating CKB transaction not only for famous scripts(1,2,3) but also for your own special script.

A Taste

Here is an example of simple command line tool for transfer some capacity from a sighash address to an arbitrary address. It use ckb-indexer to collect the live cells and sign the transaction by a raw private key. And it only use less than 50 lines of Rust code to build the transaction!

The Core Steps of Generating a CKB Transaction

  1. Build the base transaction
  2. Balance the capacity by a capacity provider and certain transaction fee rate
  3. Unlock the transaction by given keys

Step 1: Build the base transaction

A base transaction is a transaction represent the main purpose of the transaction. Such as:

  • A capacity transfer base transaction will have outputs represent the receiver/capacity pairs to transfer.
  • A dao deposit base transaction will have outputs represent the receiver/capacity pairs to deposit to.
  • A dao withdraw base transaction will have inputs represent the prepared cells to withdraw and a output reprenset the receiver and the maximum capacity can withdraw.

Please note that, in this step we not just build inputs/outputs of the transaction, we also insert cell_deps/header_deps/witnesses when needed.

Step 2: Balance the capacity

A base transaction can not be sent, there may not enough capacity to balance the transaction. So we need to collect enough live cells by given capacity provider then append them to inputs, and calculate the change cell capacity by given transaction fee rate then append it to outputs.

Calculate the change cell capacity is actually a bit tricky. For most cases, we can build a transaction its fee is perfectly fit the fee rate, which means:

let tx_fee = tx.inputs.total_capacity - tx.outputs.total_capacity;
assert_eq!(tx_fee, tx.serialized_size * fee_rate);

However, when we balancing the transaction we also collect more live cells and put them into inputs, and we may also need to put a placeholder witness for capacity provider’s lock script, then the transaction size increased. Luckily, once we have a change cell we can only tweak change cell’s capacity to fit the fee rate perfectly. Since lock/type scripts from base transaction may also require put information into witness and it will effect serialized transaction size (tx.serialized_size), so we must set them before balancing the transaction.

Step 3: Unlock the transaction

After built a balanced transaction we still can not send the transaction since its inputs are locked. To unlock the transaction we need to sign the transaction by given keys and put the signatures into witnesses.

Before we dive into the unlocking process let’s analysis several famous lock scripts first:

  • secp256k1_blake160_sighash_all
    • generate the message to sign
    • use wallet to sign the message
    • put signature into witness
  • secp256k1_blake160_multisig_all
    • generate the message to sign
    • use all participants wallet to sign the message
    • put multisig config and all signatures into witness
  • anyone_can_pay
    • if capacity not decreased and udt amount not decreased then the lock script group already unlocked
    • otherwise, just do the same steps as secp256k1_blake160_sighash_all
  • cheque claim
    • if receiver’s lock script already in inputs and have corresponding signature in witness, then the lock script group already unlocked
    • otherwise, just do the same steps as secp256k1_blake160_sighash_all

So we introduce three concepts(traits) to cover above situations:

  • Signer act like a wallet, its responsibilities are:
    • check if an id (parsed from script.args) is matched by a key
    • sign a message by a matched key
  • ScriptSigner's responsibilities are:
    • check if a script.args match currently lock script
    • generate a message to sign
    • sign the message by Signer then put the signature into witness
  • ScriptUnlocker's responsibilities are:
    • parse the script args to find out the proper ScriptSigner to use
    • check if the script group is already unlocked
    • unlock the script group by ScriptSigner or put extra information to witness
    • fill placeholder witness before balance the transaction
    • clear placeholder witness if the script group already unlocked

Below is the Rust code of above three traits:

trait Signer {
    fn match_id(&self, id: &[u8]) -> bool;
    fn sign(&self, id: &[u8], message: &[u8], recoverable: bool, tx: &TransactionView) -> Result<Bytes, Error>;

trait ScriptSigner {
    fn match_args(&self, args: &[u8]) -> bool;
    fn sign_tx(&self, tx: &TransactionView, script_group: &ScriptGroup) -> Result<TransactionView, Error>;

trait ScriptUnlocker {
    fn match_args(&self, args: &[u8]) -> bool;
    fn is_unlocked(&self, tx: &TransactionView, script_group: &ScriptGroup, tx_dep_provider: &dyn TransactionDependencyProvider) -> Result<bool, Error>;
    fn unlock(&self, tx: &TransactionView, script_group: &ScriptGroup, tx_dep_provider: &dyn TransactionDependencyProvider) -> Result<TransactionView, Error>;
    fn clear_placeholder_witness(&self, tx: &TransactionView, script_group: &ScriptGroup) -> Result<TransactionView, Error>;
    fn fill_placeholder_witness(&self, tx: &TransactionView, script_group: &ScriptGroup, tx_dep_provider: &dyn TransactionDependencyProvider) -> Result<TransactionView, Error>;

The Signer here is highly abstracted, it can be:

  • raw private ECDSA/RSA keys
  • password protected keystore
  • hardware wallet

Before unlock the transaction we need to prepare a list of unlockers, when a script group match a unlocker we use it to unlock the script group, after all script groups unlocked the transaction been unlocked and ready to send.

Implement Script Unlocker for Your Own Script

Although use your own lock script is not encouraged, because it will effect the address and an address with special code hash will likely not supported by popular wallets/exchanges/dapps. But there are cases custom lock script are intended, we just need to implement the corresponding ScriptSigner and ScriptUnlocker for it and the unlock process will just works as it is.

Implement Base Transaction Builder for Your Own Script

A base transaction builder is for building a base transaction(mentioned in step 1) by given settings. For convenience, it’s not only needed for type script may also needed for lock script. Rust SDK here abstracted the dependencies and the work flow, make it easy to implement your own builder (see trait TxBuilder for more details).

Please Have a Try!

Please have a try! Feedback/PullRequest is welcome so that we can improve the SDK. Thanks!