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.

Low-level stealth address functions for Nervos CKB using secp256k1. Import from @wraith-protocol/sdk/chains/ckb. Most developers should use the Agent Client instead. These primitives are for power users building custom stealth address integrations on CKB.

The Cell Model

CKB is fundamentally different from account-based chains like EVM or Solana. CKB uses a UTXO-based Cell model where all state is stored in Cells. A Cell has four fields:
FieldPurpose
capacityAmount of CKB stored (like a UTXO value)
lockScript that must be satisfied to spend (like an address/owner)
typeOptional script for additional validation
dataArbitrary data

Cells Are Announcements

On EVM chains, stealth address announcements are separate events emitted by an Announcer contract. On CKB, the Cell itself is the announcement. There is no separate announcer. The stealth 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)
This is 53 bytes total. The announcement data is embedded directly in the Cell’s lock script — no separate transaction or event needed.

Scanning = Querying Live Cells

Instead of querying a subgraph or parsing event logs, scanning on CKB means querying all live Cells that use the stealth-lock code hash. CKB’s built-in indexer RPC (get_cells) supports filtering by lock script code hash, so only stealth Cells are returned.

Spending = Consuming Cells

To withdraw from a stealth address, you consume the stealth Cell and create a new Cell at the destination. The stealth lock script verifies the secp256k1 signature against the pubkey hash in the args.

Installation

npm install @wraith-protocol/sdk
No additional peer dependencies required. CKB RPC calls use native fetch. Address hashing uses @noble/hashes (blake2b), which is already a direct dependency.

Import

import {
  deriveStealthKeys,
  generateStealthAddress,
  checkStealthCell,
  scanStealthCells,
  deriveStealthPrivateKey,
  encodeStealthMetaAddress,
  decodeStealthMetaAddress,
  blake160,
  fetchStealthCells,
  hashName,
  buildRegisterName,
  buildResolveName,
  metaAddressFromNameData,
  getDeployment,
  DEPLOYMENTS,
  STEALTH_SIGNING_MESSAGE,
  SCHEME_ID,
  META_ADDRESS_PREFIX,
} from "@wraith-protocol/sdk/chains/ckb";

Types

type HexString = `0x${string}`;

interface StealthKeys {
  spendingKey: HexString;       // 32-byte private key
  viewingKey: HexString;        // 32-byte private key
  spendingPubKey: HexString;    // 33-byte compressed secp256k1
  viewingPubKey: HexString;     // 33-byte compressed secp256k1
}

interface GeneratedStealthAddress {
  stealthPubKey: HexString;     // 33-byte compressed stealth public key
  stealthPubKeyHash: HexString; // 20-byte blake160 hash
  ephemeralPubKey: HexString;   // 33-byte compressed ephemeral public key
  lockArgs: HexString;          // 53 bytes: ephemeral_pub || blake160(stealth_pub)
}

// CKB doesn't have separate announcements — Cells ARE the announcements
interface StealthCell {
  txHash: HexString;
  index: number;
  capacity: bigint;              // in shannons (1 CKB = 10^8 shannons)
  lockArgs: HexString;           // 53 bytes
  ephemeralPubKey: HexString;    // extracted from lockArgs[0:33]
  stealthPubKeyHash: HexString;  // extracted from lockArgs[33:53]
}

interface MatchedStealthCell extends StealthCell {
  stealthPrivateKey: HexString;
}

Key Differences from EVM

CKB uses the same secp256k1 curve as EVM but different hash functions and a completely different state model.
AspectEVMCKB
ModelAccount-basedUTXO (Cell)
Curvesecp256k1secp256k1 (same)
AnnouncementSeparate Announcer contract eventEmbedded in Cell lock script args
Address hashkeccak256(pubkey)[12:32]blake2b(pubkey)[0:20] (blake160)
Shared secret hashkeccak256(ECDH_shared)SHA-256(ECDH_shared)
ScanningQuery subgraph for eventsQuery live Cells via get_cells RPC
Address format0x + 20-byte hexbech32m CKB address
Min balanceNone (EOA)61 CKB for cell capacity
SpendingsendTransactionConsume Cell, create new Cell
View tagsYes (fast rejection)No (check every Cell)

Constants

const STEALTH_SIGNING_MESSAGE = "Sign this message to generate your Wraith stealth keys.\n\nChain: CKB\nNote: This signature is used for key derivation only and does not authorize any transaction.";
const SCHEME_ID = 1;
const META_ADDRESS_PREFIX = "st:ckb:";

Functions

deriveStealthKeys(signature)

Derive spending and viewing key pairs from a 65-byte ECDSA signature. Identical to the EVM module.
const signature = await wallet.signMessage(STEALTH_SIGNING_MESSAGE);
const keys = deriveStealthKeys(signature as HexString);

console.log(keys.spendingKey);    // "0x..." (32-byte private key)
console.log(keys.viewingKey);     // "0x..." (32-byte private key)
console.log(keys.spendingPubKey); // "0x02..." (33-byte compressed)
console.log(keys.viewingPubKey);  // "0x03..." (33-byte compressed)
Algorithm: Same as EVM — split signature r/s, keccak256 each to get spending and viewing keys.

generateStealthAddress(spendingPubKey, viewingPubKey, ephemeralKey?)

Generate a one-time stealth address with lock script args for CKB.
const result = generateStealthAddress(
  keys.spendingPubKey,
  keys.viewingPubKey
);

console.log(result.stealthPubKey);     // "0x02..." (33-byte stealth public key)
console.log(result.stealthPubKeyHash); // "0x..." (20-byte blake160)
console.log(result.ephemeralPubKey);   // "0x03..." (33-byte ephemeral key)
console.log(result.lockArgs);          // "0x..." (53 bytes: ephemeral || blake160)
Algorithm:
  1. Generate random ephemeral key pair (r, R = r * G)
  2. Compute ECDH shared secret S = r * viewingPubKey (compressed)
  3. hashedSecret = SHA-256(S)not keccak256 like EVM
  4. stealthPubKey = spendingPubKey + hashedSecret * G
  5. stealthPubKeyHash = blake160(stealthPubKey) — blake2b with “ckb-default-hash”, first 20 bytes
  6. lockArgs = ephemeralPubKey || stealthPubKeyHash

blake160(data)

CKB’s address hashing function. blake2b with "ckb-default-hash" personalization, truncated to 20 bytes.
const hash = blake160(publicKeyBytes);
// Uint8Array (20 bytes)
The personalization string is critical — without it, hashes won’t match CKB’s on-chain verification.

checkStealthCell(cell, viewingKey, spendingPubKey)

Check if a stealth Cell belongs to you.
const result = checkStealthCell(cell, keys.viewingKey, keys.spendingPubKey);

if (result.isMatch) {
  console.log("This cell is ours");
}
Algorithm:
  1. Extract ephemeralPubKey = cell.lockArgs[0:33]
  2. Compute ECDH shared secret S = viewingKey * ephemeralPubKey
  3. hashedSecret = SHA-256(S)
  4. expectedPubKey = spendingPubKey + hashedSecret * G
  5. expectedHash = blake160(expectedPubKey)
  6. Compare expectedHash with cell.lockArgs[33:53]
No view tag optimization — every Cell is fully checked. This is acceptable because CKB’s get_cells RPC already filters to only stealth-lock Cells.

scanStealthCells(cells, viewingKey, spendingPubKey, spendingKey)

Scan an array of stealth Cells and return the ones that belong to you.
const cells = await fetchStealthCells("ckb");

const matched = scanStealthCells(
  cells,
  keys.viewingKey,
  keys.spendingPubKey,
  keys.spendingKey
);

for (const m of matched) {
  console.log(m.txHash);           // Cell outpoint
  console.log(m.capacity);         // CKB amount in shannons
  console.log(m.stealthPrivateKey); // private key to spend this Cell
}

deriveStealthPrivateKey(spendingKey, ephemeralPubKey, viewingKey)

Compute the private key that controls a specific stealth Cell.
const privateKey = deriveStealthPrivateKey(
  keys.spendingKey,
  cell.ephemeralPubKey,
  keys.viewingKey
);
// Use this key to sign the transaction that consumes the stealth Cell
Algorithm:
  1. S = viewingKey * ephemeralPubKey (shared secret)
  2. hashedSecret = SHA-256(S)not keccak256
  3. stealthPrivateKey = (spendingKey + hashedSecret) mod n

encodeStealthMetaAddress(spendingPubKey, viewingPubKey)

Encode two public keys into a CKB stealth meta-address string.
const metaAddress = encodeStealthMetaAddress(
  keys.spendingPubKey,
  keys.viewingPubKey
);
// "st:ckb:02abc...03def..."
Format: st:ckb:{spendingPubKeyHex}{viewingPubKeyHex} — 132 hex chars (two 33-byte compressed secp256k1 keys), same structure as EVM.

decodeStealthMetaAddress(metaAddress)

Decode a CKB meta-address back into its component public keys.
const { spendingPubKey, viewingPubKey } = decodeStealthMetaAddress(
  "st:ckb:02abc...03def..."
);

End-to-End Flow

import {
  deriveStealthKeys,
  generateStealthAddress,
  scanStealthCells,
  deriveStealthPrivateKey,
  encodeStealthMetaAddress,
  decodeStealthMetaAddress,
  fetchStealthCells,
  STEALTH_SIGNING_MESSAGE,
} from "@wraith-protocol/sdk/chains/ckb";
import type { HexString } from "@wraith-protocol/sdk/chains/ckb";

// 1. Recipient: derive keys from wallet signature
const sig = await wallet.signMessage(STEALTH_SIGNING_MESSAGE);
const keys = deriveStealthKeys(sig as HexString);

// 2. Recipient: publish stealth meta-address
const metaAddress = encodeStealthMetaAddress(keys.spendingPubKey, keys.viewingPubKey);
// Share "st:ckb:..." or register as a .wraith name via buildRegisterName()

// 3. Sender: generate stealth address from meta-address
const { spendingPubKey, viewingPubKey } = decodeStealthMetaAddress(metaAddress);
const stealth = generateStealthAddress(spendingPubKey, viewingPubKey);

// 4. Sender: create a Cell with stealth-lock
//    lock.code_hash = stealth-lock code hash
//    lock.args = stealth.lockArgs (53 bytes: ephemeral_pub || blake160)
//    capacity = payment amount (minimum 61 CKB)

// 5. Recipient: scan stealth Cells
const cells = await fetchStealthCells("ckb");
const matched = scanStealthCells(
  cells,
  keys.viewingKey,
  keys.spendingPubKey,
  keys.spendingKey
);

// 6. Recipient: spend from stealth Cell
for (const m of matched) {
  // Build a CKB transaction:
  //   Input: the matched stealth Cell
  //   Output: new Cell at destination with regular lock
  //   Witness: signature with m.stealthPrivateKey
  // Submit via CKB RPC
}

Names

.wraith name registration on CKB uses the Cell model. Each name is a live Cell whose type script identifies the name and whose data holds the stealth meta-address. Ownership is proven by the Cell’s lock script — whoever can spend the Cell owns the name. This is fundamentally different from EVM and Solana, where ownership is proven by a signature from the spending key.

hashName(name)

Compute the blake2b hash of a name string. The result is used as the type script args to identify the name Cell.
const nameHash = hashName("alice");
// "0x..." (32-byte blake2b hash with "ckb-default-hash" personalization)

buildRegisterName({ name, spendingPubKey, viewingPubKey })

Build the type script and Cell data for creating a .wraith name Cell. Returns the type script object and the 66-byte data payload.
import {
  buildRegisterName,
  deriveStealthKeys,
  getDeployment,
  STEALTH_SIGNING_MESSAGE,
} from "@wraith-protocol/sdk/chains/ckb";
import type { HexString } from "@wraith-protocol/sdk/chains/ckb";

const sig = await wallet.signMessage(STEALTH_SIGNING_MESSAGE);
const keys = deriveStealthKeys(sig as HexString);
const deployment = getDeployment("ckb");

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

// Create a Cell with:
//   lock: your wallet's lock script (you are the owner)
//   type: typeScript (wraith-names-type with args = blake2b("alice"))
//   data: 66 bytes (spending_pub || viewing_pub)
//   capacity: enough to cover the Cell size (minimum ~130 CKB)
The type script returned uses the wraith-names-type code hash from the deployment config. The args field is hashName(name).

buildResolveName({ name })

Build the type script for querying a name Cell. Use this with the CKB get_cells RPC to find the Cell that holds a name’s meta-address.
import { buildResolveName, metaAddressFromNameData } from "@wraith-protocol/sdk/chains/ckb";

const { typeScript } = buildResolveName({ name: "alice" });

// Query CKB for live Cells with this type script
const result = await fetch(rpcUrl, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    id: 0,
    jsonrpc: "2.0",
    method: "get_cells",
    params: [{
      script: {
        code_hash: typeScript.codeHash,
        hash_type: typeScript.hashType,
        args: typeScript.args,
      },
      script_type: "type",
    }, "asc", "0x1"],
  }),
});

const data = await result.json();
const cell = data.result?.objects?.[0];

if (cell) {
  const metaAddress = metaAddressFromNameData(cell.output_data);
  // { spendingPubKey: "0x02...", viewingPubKey: "0x03..." }
}

metaAddressFromNameData(data)

Parse the 66-byte Cell data from a name Cell into its component public keys.
const { spendingPubKey, viewingPubKey } = metaAddressFromNameData(cellData);
// spendingPubKey: "0x02..." (33-byte compressed secp256k1)
// viewingPubKey: "0x03..." (33-byte compressed secp256k1)
The data format is spendingPubKey (33 bytes) || viewingPubKey (33 bytes) = 66 bytes total.

Registering a Name

import {
  deriveStealthKeys,
  buildRegisterName,
  getDeployment,
  STEALTH_SIGNING_MESSAGE,
} from "@wraith-protocol/sdk/chains/ckb";
import type { HexString } from "@wraith-protocol/sdk/chains/ckb";

const sig = await wallet.signMessage(STEALTH_SIGNING_MESSAGE);
const keys = deriveStealthKeys(sig as HexString);
const deployment = getDeployment("ckb");

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

// Build a CKB transaction:
//   Output: {
//     capacity: "0x" + (130_00000000n).toString(16),
//     lock: walletLockScript,       // your wallet's lock = you own the name
//     type: {
//       codeHash: typeScript.codeHash,
//       hashType: typeScript.hashType,
//       args: typeScript.args,       // blake2b("alice")
//     },
//   }
//   Output data: data               // 66 bytes meta-address
//   Cell dep: deployment.cellDeps.namesType
// Submit via CKB RPC
Ownership is determined by the Cell’s lock script. Whoever can spend the Cell controls the name. To transfer a name, consume the Cell and create a new one with a different lock script. No signature verification against the spending key is needed — the lock script handles authorization.

Resolving a Name

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

const deployment = getDeployment("ckb");
const { typeScript } = buildResolveName({ name: "alice" });

// Query for the name Cell
const response = await fetch(deployment.rpcUrl, {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    id: 0,
    jsonrpc: "2.0",
    method: "get_cells",
    params: [{
      script: {
        code_hash: typeScript.codeHash,
        hash_type: typeScript.hashType,
        args: typeScript.args,
      },
      script_type: "type",
    }, "asc", "0x1"],
  }),
});

const result = await response.json();
const cell = result.result?.objects?.[0];

if (cell) {
  const { spendingPubKey, viewingPubKey } = metaAddressFromNameData(cell.output_data);
  const stealth = generateStealthAddress(spendingPubKey, viewingPubKey);
  // Send a stealth payment to alice.wraith on CKB
}

CKB Names vs Other Chains

AspectEVM / SolanaCKB
Ownership proofSpending key signatureCell lock script (whoever can spend)
StorageContract state / PDALive Cell
Lookupresolve(name) contract callget_cells RPC filtered by type script
TransferNot supported (re-register)Consume Cell, create with new lock
Data size64 bytes (ed25519) or 66 bytes (secp256k1)66 bytes (secp256k1)
Name hashkeccak256 (EVM), SHA-256 (Solana/Stellar)blake2b with “ckb-default-hash”

CKB-Specific Considerations

  • Minimum capacity: A stealth-lock Cell requires at least 61 CKB due to the 53-byte args. Senders must send at least this amount.
  • No view tags: Every Cell must be fully checked. CKB’s get_cells RPC already filters by lock script code hash, so only stealth Cells are examined.
  • blake2b personalization: CKB uses "ckb-default-hash" as the blake2b personalization parameter. This must be included or hashes won’t match on-chain verification.
  • UTXO spending: Withdrawing means consuming the Cell and creating a new one at the destination. The transaction fee is deducted from the Cell’s capacity.
  • Name ownership: On CKB, name ownership is proven by the Cell’s lock script, not by a signature from the spending key like on EVM. Whoever can spend the name Cell controls the name.

Chain Deployments

getDeployment(chain)

const deployment = getDeployment("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 },
//   },
// }

Supported Networks

NetworkStatus
CKB Testnet (Pudge)Live

Fetching Stealth Cells

fetchStealthCells(chain?)

Fetches all live stealth Cells from CKB using the get_cells RPC method, filtered by the stealth-lock code hash. Handles pagination automatically.
const cells = await fetchStealthCells("ckb");
// Returns StealthCell[] — ready to pass to scanStealthCells()
This replaces the subgraph/event-based scanning used on other chains. CKB’s native indexer makes this efficient without external infrastructure.