cellula-sdk
v0.2.1
Published
TypeScript client for the Cells naming protocol on Nervos CKB
Maintainers
Readme
cellula-sdk
TypeScript client for Cells, the permissionless .cell naming protocol on
Nervos CKB. It resolves names and records, resolves records back to names, and drives
every write action (register, edit, transfer, renew, recycle, delegate a manager, set
a primary name), signed by the caller's own wallet, with no operator in the loop.
Built on @ckb-ccc/core. The wire codec
(src/codec.ts) is a 1:1 mirror of the on-chain Rust cells-core, drift-checked
against real on-chain bytes in test/codec.test.ts, so the SDK and the contract can
never disagree. It handles both the v1 layout and the v2 manager layout (decision
0006) transparently.
Getting it
npm i cellula-sdk @ckb-ccc/core @ckb-ccc/sporeCCC is a peer dependency, not a dependency, and that is deliberate. It carries shared state, so two copies in one tree do not recognise each other's objects; pinning it here would have handed every application a second copy of whatever it already had. Measured: with CCC as an ordinary dependency, an application on 1.22.0 ended up with ours at 1.19.1 nested underneath. As a peer, it keeps one.
@ckb-ccc/spore is a peer for the same reason; it carries deeds. Each Spore release pins one
CCC exactly (1.6.9 pins 1.19.1, 1.6.12 pins 1.21.0, 1.6.15 pins 1.23.0), so take the one that
pins the CCC you use, or npm nests a second CCC under it.
Inside this repository the same specifier resolves to the source: the web app writes
cellula-sdk and its bundler maps it to sdk/src/index.ts (see ui/vite.config.ts),
while the scripts and examples import the path directly. The runnable version of every
snippet on this page is in examples/, and node examples/resolve.ts alice.cell works
with no key and no configuration file.
You may not need this at all. Reading a name is one HTTP call to the resolver, and
cellula-id wraps exactly that with no keys
and no dependencies. Take this package when you need to write: register, renew,
transfer, sell, protect. See ../docs/RESOLVER.md for the plain
HTTP path.
Read
import { ccc } from '@ckb-ccc/core'
import { CellsClient, deploymentFor } from 'cellula-sdk'
const cells = new CellsClient(new ccc.ClientPublicMainnet(), deploymentFor('mainnet'))
await cells.resolve('alice.cell', '60') // ETH address (0x..) or null
await cells.addresses('alice.cell', '60') // all address.60 records
await cells.records('alice.cell', 'profile.')
await cells.reverse('0xabc...', '60') // names whose records point at this address
await cells.primaryName('ckt1...') // a wallet's verified primary name, or null
await cells.list() // every live name, with its recordslist() reads each name's records, one round trip per name. For a list people page
through, read the names without their records and fetch the records of the rows on screen:
import { payMethodsOf } from 'cellula-sdk'
const names = await cells.liteList() // label, id, owner, expiry: one scan per 100 names
const shown = await cells.hydrateMany(names.slice(0, 10))
payMethodsOf(shown[0].records) // ['ckb', 'lightning', ...]deploymentFor carries the code cells this release was built against. They move when a
contract is upgraded in place (the code hashes do not), so after an upgrade take the next
release; the resolver's /verify lists the live ones. Reading needs only the type script,
which does not move.
primaryName is trustless without any extra contract: it reads the wallet's
reverse-record cell, then forward-resolves the claimed name and returns it only if
that name is actually owned by the same wallet, so a forged record resolves to
nothing.
Meals (decision 0037): a payment into the lock of a name's deed that says which name it was for, with an optional note. Only these feed a name's being, and only a name held as a deed has one; a transfer into the address the name publishes is a payment to its owner, not a meal. What the being eats stays under that lock, the belly, until the deed's holder empties it.
import { addMeal, bellyOf, deedLock, scanMeals } from 'cellula-sdk'
const deed = await cells.deedOf('alice.cell') // null for a name owned by a plain key
const belly = await deedLock(client, deed.sporeTypeHash) // where a meal goes; 73 CKB is the smallest cell it holds
const tx = ccc.Transaction.from({ outputs: [{ lock: belly, capacity: 73_00000000n }], outputsData: ['0x'] })
await tx.completeInputsByCapacity(signer)
addMeal(tx, { to: 'alice.cell', note: 'congrats on the launch' }) // after the inputs, before the fee
await tx.completeFeeBy(signer, 2000)
const { meals } = await scanMeals(client, belly, 'alice.cell') // newest first: payer, amount, note
const { total } = await bellyOf(client, deed.sporeTypeHash) // eaten and not yet taken
await cells.emptyBelly(holderSigner, 'alice.cell') // the holder takes it, the Spore in the inputsAccept .cell in a CCC app
@ckb-ccc/core 1.23.0 added addressResolver to the client config (ckb-devrel/ccc#575): a
hook Address.fromString calls for a string it cannot parse. This package ships the .cell
one, so any app that reads addresses through fromString takes names with one line:
import { ccc } from '@ckb-ccc/core'
import { cellResolver } from 'cellula-sdk'
const client = ccc.ClientPublicMainnet.open({ addressResolver: cellResolver() }).value
const to = await ccc.Address.fromString('alice.cell', client) // the CKB address alice.cell publishesA real address still parses first, so nothing else changes. For a .cell name the resolver
asks the cellula.id resolver where the name's cell is, then checks that answer against the
client you gave it: the cell is live, its type is the namespace's, its data names the label,
and the payout sits in records whose hash the cell commits to. Only then is the address in
them returned. A wrong or stale hint falls through to reading the chain, so the hint can
speed things up but never point elsewhere. It answers undefined, which fromString reports
as not found, for a name that is unregistered, expired, withdrawn under the disputes policy,
or without a CKB payout. cellResolver({ api: null }) reads the chain only.
node examples/resolve-ccc.ts developer.cell runs it against testnet.
This package builds against @ckb-ccc/core 1.19.1, the version the wallets pin
(decision 0031); the resolver is a
plain object, so an app on 1.23.0 or later uses it as is.
Write
const signer = new ccc.SignerCkbPrivateKey(client, '0x...') // any wallet signer
// Permissionless register (commit-reveal handled for you):
const { available } = await cells.availability('satoshi')
if (available) {
await cells.registerWithCommit(signer, 'satoshi', {
records: [{ key: 'address.60', label: '', value: '0x...20 bytes', ttl: 300 }],
})
}
// Owner or manager:
await cells.editRecords(signer, 'satoshi.cell', [{ key: 'address.60', label: '', value: '0x...', ttl: 300 }])
// Owner only:
await cells.setManager(signer, 'satoshi.cell', managerAddress) // delegate (migrates v1 -> v2)
await cells.transfer(signer, 'satoshi.cell', newOwnerAddress) // resets the manager
await cells.renew(signer, 'satoshi.cell', newExpiredAt)
await cells.setPrimary(signer, 'satoshi.cell') // or '' to clear
// Permissionless:
await cells.recycle(signer, 'expired.cell')
// Buying a listed name: the listing is read again when you buy, so pass the one you showed,
// and a listing that changed in between is refused before the wallet is asked.
const offer = await cells.listing('satoshi.cell')
if (offer) await cells.buy(signer, 'satoshi.cell', { maxPriceShannons: offer.priceShannons, outPoint: offer.outPoint })register finds the predecessor, builds the splice (predecessor preserved plus a new
cell owned by the signer), funds capacity and fee from the signer, adds the
commit-cell header dep for the L-2 future-expiry check, and broadcasts. No sequencer,
no ConfigCell, no predecessor-owner signature.
Run
Node 24 (native TypeScript, no build step). npm install, then:
npm test # codec drift tests (offline), incl. v2 + reverse-record
node examples/resolve.ts alice.cell # live resolve against testnet
node examples/manage.ts # full write lifecycle (live)
node examples/delegate.ts [label] # manager delegation + primary name (live)examples/delegate.ts reads a delegated-manager key from CELLS_MANAGER_KEY or
CELLS_MANAGER_KEY_FILE (default .testnet/manager.key).
