Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.usewraith.xyz/llms.txt

Use this file to discover all available pages before exploring further.

CKB scripts (smart contracts) for stealth address operations on the Nervos CKB network. Written in Rust and compiled to RISC-V binaries that run on CKB-VM.

The Cell Model — Why CKB Is Different

CKB uses a UTXO-based Cell model, not accounts. Every piece of on-chain state is a Cell:
Cell {
  capacity: 200_00000000,        // 200 CKB (in shannons)
  lock: {                        // who can spend this Cell
    code_hash: "0xabc...",       // hash of the lock script code
    hash_type: "data2",
    args: "0x...",               // arguments passed to the lock script
  },
  type: null,                    // optional type script
  data: "0x",                    // arbitrary data
}

No Separate Announcer

On EVM chains, stealth payments require two operations: transfer funds and emit an announcement event. On CKB, the Cell itself is the announcement. The lock script args contain both the ephemeral public key and the stealth address hash:
lock.args = ephemeral_pubkey (33 bytes) || blake160(stealth_pubkey) (20 bytes)
             ^^^^^^^^^^^^^^^^^^^^^^^^^     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
             announcement data             stealth address identifier
This 53-byte args field serves as both the lock condition and the announcement. No separate contract call, no event indexing infrastructure — just create a Cell and the recipient can find it.

Scanning = Querying Cells

CKB has a built-in indexer with an RPC method get_cells that filters by lock script code hash. To scan for incoming payments, query all live Cells using the stealth-lock code hash. Each Cell’s args contains the ephemeral key needed for the ECDH check.

Spending = Consuming Cells

To withdraw funds, build a transaction that:
  1. Inputs: The stealth Cell being consumed
  2. Outputs: A new Cell at the destination address
  3. Witness: secp256k1 signature with the stealth private key
The stealth lock script verifies the signature against the pubkey hash in the args.

Script Set

ScriptTypePurpose
wraith-stealth-lockLock ScriptVerifies secp256k1 signatures for stealth Cells
wraith-names-typeType Script.wraith name registration and resolution
wraith-stealth-lock handles stealth payments. wraith-names-type handles .wraith name registration.

wraith-stealth-lock

A lock script that verifies secp256k1 signatures against the stealth public key hash embedded in the script args.

Args Format

53 bytes total:
[0:33]  — ephemeral_pubkey    (33-byte compressed secp256k1 public key)
[33:53] — blake160(stealth_pub)  (20-byte blake2b hash of stealth public key)

Verification Flow

fn program_entry() -> i8 {
    let script = load_script();
    let args = script.args().raw_data();

    // 1. Args must be exactly 53 bytes
    if args.len() != 53 {
        return ERROR_ARGS_LENGTH;
    }

    // 2. Extract the stealth pubkey hash
    let pubkey_hash = &args[33..53]; // blake160 of stealth pubkey

    // 3. Load signature from witness (65 bytes: r || s || v)
    let witness_args = load_witness_args(0, Source::GroupInput);
    let signature = witness_args.lock();

    // 4. Load transaction hash as the message to verify
    let tx_hash = load_tx_hash();

    // 5. Recover public key from signature
    // 6. Compute blake160(recovered_pubkey)
    // 7. Compare with pubkey_hash from args
    verify_secp256k1(tx_hash, signature, pubkey_hash)
}
The script uses ckb-std crypto syscalls or ckb-auth for secp256k1 signature recovery and verification.

blake160

CKB’s standard address hashing:
fn blake160(data: &[u8]) -> [u8; 20] {
    let hash = blake2b_256_with_personalization(data, b"ckb-default-hash");
    hash[0..20]
}
The "ckb-default-hash" personalization is required. Without it, hashes won’t match CKB’s standard address format.

How It Differs from Standard CKB Locks

CKB’s default lock script (secp256k1-blake160-sighash-all) uses 20-byte blake160 args containing the owner’s pubkey hash. The stealth lock extends this to 53 bytes by prepending the ephemeral public key. The verification logic is the same — recover the public key from the signature and check the blake160 hash.

Usage

import { generateStealthAddress, getDeployment } from "@wraith-protocol/sdk/chains/ckb";

const deployment = getDeployment("ckb");
const stealth = generateStealthAddress(spendingPubKey, viewingPubKey);

// Create a Cell with stealth-lock
const output = {
  capacity: "0x" + (200_00000000n).toString(16), // 200 CKB
  lock: {
    codeHash: deployment.contracts.stealthLockCodeHash,
    hashType: "data2",
    args: stealth.lockArgs, // 53 bytes: ephemeral_pub || blake160(stealth_pub)
  },
};

wraith-names-type

A Type Script for .wraith name registration on CKB. Each name is stored as a live Cell. The type script validates creation, update, and destruction of name Cells.

Deployed Code Hash

0xc133817d433f72ea16a2404adaf961524e9572c8378829a21968710d6182e20d

Cell Dep

tx: 0x9acd640d35eadd893b358dddd415f4061fe81cb249e8ace51a866fee314141b8
index: 0

Cell Layout

A .wraith name Cell has three meaningful fields:
FieldContent
type.args32-byte blake2b hash of the name string (personalization: "ckb-default-hash")
data66 bytes: spendingPubKey (33 bytes) || viewingPubKey (33 bytes)
lockOwner’s lock script — whoever can spend this Cell owns the name
The type script args uniquely identify the name. Two Cells cannot have the same type script (same code hash + same args) because the type script enforces uniqueness at creation time.

How It Works

The type script runs on every transaction that creates, updates, or destroys a name Cell. It inspects the transaction’s inputs and outputs to determine the operation:
  • Create: The type script appears in an output but not in any input. A new name is being registered.
  • Update: The type script appears in both an input and an output. The name’s data is being changed.
  • Destroy: The type script appears in an input but not in any output. The name is being released.

Validation Rules

Data must be exactly 66 bytes. The data field holds two 33-byte compressed secp256k1 public keys (spending + viewing). The type script rejects any Cell with data that is not exactly 66 bytes. This applies to both creation and update. Create: When a new name Cell is created, the type script verifies:
  1. No existing live Cell has the same type script (same code hash and args). CKB enforces this natively — two live Cells cannot share the same type script.
  2. The output data is exactly 66 bytes.
Update: When a name Cell is consumed and recreated in the same transaction:
  1. The new output data must be exactly 66 bytes.
  2. The lock script on the input Cell must be satisfied (the owner authorized the transaction).
Destroy: When a name Cell is consumed without being recreated:
  1. The lock script on the input Cell must be satisfied (the owner authorized the destruction).
  2. No further validation — the name is released and can be re-registered.

Ownership Model

On EVM and Solana, name ownership is proven by a signature from the spending key embedded in the meta-address. The contract verifies this signature on every register/update/release call. On CKB, ownership is proven by the Cell’s lock script. The lock script determines who can spend (consume) the Cell. This is more flexible:
  • A standard secp256k1 lock means a single key controls the name.
  • A multisig lock means multiple parties must agree to update or transfer the name.
  • Any custom lock script can control a name.
To transfer a name, consume the name Cell and create a new one with the same type script but a different lock script. The new lock script’s owner now controls the name.

Verification Flow

fn program_entry() -> i8 {
    let script = load_script();

    let mut has_input = false;
    let mut has_output = false;

    // Check inputs for Cells with this type script
    for i in 0.. {
        match load_cell_type(i, Source::GroupInput) {
            Ok(Some(t)) if t.as_slice() == script.as_slice() => {
                has_input = true;
                break;
            }
            Err(SysError::IndexOutOfBound) => break,
            _ => continue,
        }
    }

    // Check outputs for Cells with this type script
    for i in 0.. {
        match load_cell_type(i, Source::GroupOutput) {
            Ok(Some(t)) if t.as_slice() == script.as_slice() => {
                has_output = true;
                // Validate data is exactly 66 bytes
                let data = load_cell_data(i, Source::GroupOutput);
                if data.len() != 66 {
                    return ERROR_DATA_LENGTH;
                }
                break;
            }
            Err(SysError::IndexOutOfBound) => break,
            _ => continue,
        }
    }

    // Create: output exists, no input -> OK (CKB prevents duplicate type scripts)
    // Update: both exist -> OK (lock script on input already verified)
    // Destroy: input exists, no output -> OK (lock script on input already verified)
    0
}

Usage

import {
  hashName,
  buildRegisterName,
  buildResolveName,
  metaAddressFromNameData,
  getDeployment,
} from "@wraith-protocol/sdk/chains/ckb";

const deployment = getDeployment("ckb");

// Register a name
const { typeScript, data } = buildRegisterName({
  name: "alice",
  spendingPubKey: keys.spendingPubKey,
  viewingPubKey: keys.viewingPubKey,
});

const output = {
  capacity: "0x" + (130_00000000n).toString(16),
  lock: walletLockScript,
  type: {
    codeHash: typeScript.codeHash,
    hashType: typeScript.hashType,
    args: typeScript.args,
  },
};
// Cell dep: { txHash: "0x9acd640d...", index: 0, depType: "code" }
// Output data: data (66 bytes)

// Resolve a name
const { typeScript: resolveType } = buildResolveName({ name: "alice" });
// Query get_cells with script_type: "type" and the resolveType script
// Parse the Cell's output_data with metaAddressFromNameData()

Project Structure

contracts/
  ckb/
    Cargo.toml
    contracts/
      wraith-stealth-lock/
        Cargo.toml
        src/main.rs
      wraith-names-type/
        Cargo.toml
        src/main.rs
    tests/
      src/tests.rs

Deployment

Build

Compile to RISC-V binary:
cargo build --target riscv64imac-unknown-none-elf --release

Deploy

Deploy the compiled binary as a Cell on CKB testnet. The Cell’s data hash becomes the code_hash used in stealth lock scripts.
# Deploy using ckb-cli or a deployment script
ckb-cli deploy gen-txs \
  --deployment-config deployment.toml \
  --migration-dir migrations

Record Deployment Info

After deployment, record:
  • Code hash: The blake2b hash of the deployed binary (used as lock.code_hash in stealth Cells)
  • Cell dep: The transaction hash and index where the script binary Cell lives (required in every transaction that uses the lock)
Update the SDK’s deployments.ts:
export const DEPLOYMENTS = {
  ckb: {
    network: "testnet",
    rpcUrl: "https://testnet.ckbapp.dev",
    explorerUrl: "https://pudge.explorer.nervos.org",
    contracts: {
      stealthLockCodeHash: "0x...",
      namesTypeCodeHash: "0xc133817d433f72ea16a2404adaf961524e9572c8378829a21968710d6182e20d",
    },
    cellDeps: {
      stealthLock: {
        txHash: "0x...",
        index: 0,
      },
      namesType: {
        txHash: "0x9acd640d35eadd893b358dddd415f4061fe81cb249e8ace51a866fee314141b8",
        index: 0,
      },
    },
  },
};

Testing

Tests use CKB’s native testing framework with ckb-testtool: wraith-stealth-lock:
  • Args validation — rejects args that aren’t exactly 53 bytes
  • Signature verification — valid stealth key signature passes
  • Wrong key rejection — signature from a different key fails
  • blake160 correctness — hash matches CKB’s standard implementation
wraith-names-type:
  • Data validation — rejects data that isn’t exactly 66 bytes
  • Create — allows creating a name Cell with valid 66-byte data
  • Update — allows updating data when the owner’s lock is satisfied
  • Destroy — allows destroying a name Cell when the owner’s lock is satisfied
  • Invalid data on create — rejects Cell creation with wrong data length
  • Invalid data on update — rejects Cell update with wrong data length

Differences from Other Chains

AspectEVM (Solidity)Stellar (Soroban)Solana (Anchor)CKB
ModelAccountAccountAccountUTXO (Cell)
LanguageSolidityRustRust (Anchor)Rust (RISC-V)
AnnouncementSeparate eventSeparate eventSeparate eventEmbedded in Cell
Scanning sourceSubgraphSoroban RPCProgram logsget_cells RPC
Script count4 contracts4 contracts3 programs2 scripts (lock + type)
Address hashkeccak256N/AN/Ablake2b (“ckb-default-hash”)
Shared secret hashkeccak256SHA-256SHA-256SHA-256
Min balanceNone1 XLM~0.00089 SOL61 CKB
NamesOn-chain contractOn-chain contractOn-chain programType Script (Cell-based)
The key architectural difference: CKB needs only two scripts to support stealth payments and names. The stealth lock handles payments (announcements are implicit in the Cell), and the names type script handles .wraith name registration. Other chains need 3-4 separate contracts for the same functionality.