LS-IDL: a Lock Script Interface Description Language for CKB (derive, validate, commit)

Hey, everyone

I have spent most of my time on CKB looking at low-level transaction structure and scripts. I love that scripts on CKB are fully programmable, unlke Bitcoin where you have a fixed set of script types (P2TR, P2WPKH, etc.) with fixed witness shapes.

However, that flexibility creates a real problem when building transactions. Because I can lock a cell with literally any script I write, there is no machine-readable description of what witness that script expects. Wallets and tx builders either have out-of-band knowledge of the format, or they guess. If the guess is wrong, the transaction fails. The bad thing is that it doesn’t fail at construction time, but with a VM error after it hits the chain.

The worst case: a group of people passing a partially signed transaction around. Some add witnesses in the wrong format, and nobody finds out until the transaction is rejected on-chain. That is bad experience.

This post describes what I am building to address it.

The idea: lock scripts with machine-readable interface

In Ethereum, every smart contract has an Application Binary Interface, which is a JSON description of its functions, evemts, argument types, etc. You can find a contract’s ABI, decode its calldata, and validate that you have built a transaction correctly before submitting it. CKB currently has no such thing. LS-IDL is my attempt to build it.

The system has two Rust crates:

ckb-idl-derive: this is the script side. It is a procedural macro that lets you declare your witness layout directly into your Rust lock script and automatically generate a machine-readable idl.json at compile time. It also generates the runtime deserializer, so the struct you annotate is the one your script actually uses.

ckb-idl-client: this is the tooling side. It is a library that fetches an IDL, verifies it against an onchain commitment in the deployed code cell, and then uses the IDL to structurally validate a proposed witness buffer before you submit a transaction.

Both are written in Rust. The wire format is documented in a test-vectors.json so any reimplementation in Typescript, Python, or some other language can verify correctness against the same cases.

How the Script side Works:

When writing scripts with this, the changes would be adding: #derive(CkbWitness)] to a named-field struct in your lock script crate.

use ckb_idl_derive::CkbWitness;

#[derive(CkbWitness)]
pub struct Witness {
    #[witness(description = "Preimage whose blake2b-256 hash must match the hash in script args")]
    pub preimage: Vec<u8>,
}

Each of the fields would be described with an optional description and required field, which defaults to true if not available.

At compile time, the macro writes idl.json next to your Cargo.toml thus:

{
  "witness": [
    {
      "name": "preimage",
      "type": "bytes",
      "required": true,
      "description": "Preimage whose blake2b-256 hash must match the hash in script args"
    }
  ]
}

And it generates a from_witness_args method on the struct due to the macro, so your script logic looks like this:

fn check_hash() -> Result<(), Error> {
    let witness = Witness::from_witness_args(0, Source::GroupInput)
        .map_err(|_| Error::MissingWitness)?;

    let actual = blake2b_256(witness.preimage.as_slice());
    // compare to expected hash in script args...
}

The above assumes check_hash() is the function that ultimately protects the script (secures the script with your lock).

The struct is live code, not documentation. The same type declaration that drives the IDL also drives the runtime deserializer, so that they do not drift apart.

Another example of this is:

#[derive(CkbWitness)]
pub struct Witness {
    #[witness(description = "secp256k1 ECDSA signature authorising the spend")]
    pub signature: [u8; 65],

    #[witness(description = "Unix timestamp in milliseconds; cell cannot be spent before this")]
    pub unlock_after_ms: u64,

    #[witness(required = false, description = "Optional auxiliary payload")]
    pub extra: Vec<u8>,
}

For multiple fields, and this produces:

{
  "witness": [
    { "name": "signature",       "type": "secp256k1_sig", "required": true  },
    { "name": "unlock_after_ms", "type": "uint64",        "required": true  },
    { "name": "extra",           "type": "bytes",         "required": false }
  ]
}

There is a list of currently supported types: u8, u32, u64, [u8; 65](which is taken as a secp256k1 sig), [u8; 33] (secp256k1 pubkey), [u8; 64] (schnorr sig), and Vec<u8>. Using anything else is a compile-time error. Vec<u8> should cover for most array types with unsupported lengths, and intended fixed-length arrays can be handled in-code as Vecs, with length validation. (The first four bytes of the vec are read as the length prefix, in little-endian). The type registry is intentionally small, I tried to include types that have a single unambiguous CKB meaning at this size, hoping that the community would indicate what is missing.

How the Client side works:

When you deploy a script, you sha256-hash the idl.json and append the 32 bytes to the code cell data.

code_cell_data = risc_v_binary || sha256(idl.json)

The client then has three things to do when you want to build a transaction spending a cell locked by that script:

  • Fetch the IDL from a registry (in my examples, I load it locally)
  • Verify it: take the last 32 bytes of the code cell data and check that sha256(idl_json) == those 32 bytes. If this passes, you know with certainty that the IDL you have fetched is the correct original IDL used in the exact deployed code. The purpose of this step is so you do not have to trust any registry.
  • Validate your witness buffer structurally: parse the witness buf field-by-field against the IDL’s declared types, catch FieldTooShort, TrailingBytes, and UnknownType errors before the transaction leaves your machine.
// Verify the IDL is authentic
client.verify(code_hash, &idl_json_bytes, &code_cell_data)?;

// Validate your proposed witness
let validated = client.validate_witness_bytes(&idl_doc.witness, &wire_bytes)?;
// If Ok, the encoding is structurally valid. Submit the transaction.

Structural validation is the correctness-check before submission. Whether a signature is actually cryptographically valid, whether a timelock has passed, those semantic checks are enforced by the VM. The IDL client handles structure, and leaves actual semantics for the chain.

What is Missing: the registry:

Currently, an uncertain part of the system right now is the fetch step. In my examples, I load the scripts locally, not fetch from a registry. For a scripts to be used by anyone, the IDL has to be available to that person that wants to use it. I have not built a registry yet.

Lately, several people in the ecosyste have talked about script registries, so I thought this could naturally fit into one of the implementations, without me needing to build it myself. The registry would store the IDL alongside the code hash, and provide a query-able endpoint for the client. I wanted to bring this to the community before deciding where to go next.

If you are one of the developers building a registry, I am open and willing to discuss the specs of what I need.

What is currently built:

  • ckb-idl-derive: the proc macro, the type registry and the from_witness_args code generator exist
  • ckb-idl-types: a no_std-compatible companion crate with the WitnessError type used by generated code
  • ckb-idl-client: the IDL commitment verification, structural witness validation, and the test vector runner
  • Two reference lock scripts using the system: simple-lock (simple preimage hash), and timelock-lock (signature + timestamp + original payload)
  • 16 canonical test vectors covering every field type, every error condition, and both reference scripts — these are the normative specification for any reimplementation

Currently underway is a TypeScript implementation, that will be tested against those same test vectors. Also, more complicated scripts than the simple-lock and timelock-lock are currently being written and tested using the LS-IDL

Currently, this only supports Lock Scripts, but Type Scripts are in the roadmap. Once this is working and running, and adopted with Lock Scripts, it would be improved to also support TypeScripts.

Known Limitations:

  • No [u8; 32] support: a 32-byte array is ambiguous (hash? pubkey? arbitrary bytes?). A #[witness(type = "blake2b_hash")] override attribute is the planned path, but still reconsidering if that is good design.
  • Wire format not recorded in the IDL: the JSON describes field names and types but not the length-prefix encoding. A future “encoding” top-level key will fix this.
  • No Molecule support: scripts using WitnessArgs or Molecule-generated types can’t use the macro on those fields today.
  • required = false is documentation only: the runtime decoder doesn’t yet treat optional fields differently. This needs Option field types and updated codegen.
  • The registry doesn’t exist yet: this is the biggest gap needed to be covered for this to work yet.

Questions I am uncertain about:

  • Registry design: where should IDLs live? Should a script-registry own this, or a separate IDL-specific registry, and why? What does a healthy fetch path look like for a wallet that encounters an unknown script?
  • Type registry scope: the current handful of types cover some common cases. How many real scripts are people writing that need types that are not on the list? Should the types (fixed array types of lengths 33, 64, or 65) even have those tags attached to them (schnorr sig, secp256k1 pubkey, etc.)? What other types could be added
  • TypeScript priority: How important would an implementation in TypeScript matter for adoption? I have not had much reason to write any frontend code or node scripts in CKB, but would those clients make use of this as well?

Repositories:

What are your thoughts on this?

10 Likes

A quick update for everyone following LS-IDL: the Unified Registry now supports the LS-IDL workflow.

You can connect a CKB testnet or mainnet wallet and try it directly here:


How to Use the LS-IDL Registry Profile

So as @OWK50GA explains above, LS-IDL describes the witness fields expected by a CKB Lock Script.

The Unified Registry currently shipped along with CellScript’s CLI toolchain preserves that description as exact bytes and binds it to thedeployed executable with this commitment:

code Cell data = executable bytes || SHA-256(raw idl.json bytes)

This tutorial follows the complete implemented workflow:

raw idl.json
    -> cellc validate
    -> cellc bind to a clean CKB executable
    -> cellc bundle and publish
    -> deploy the bound executable
    -> record chain evidence
    -> cellc fetch the exact IDL by Script identity

The word raw matters. Whitespace, key order, and the final newline are part of the committed IDL identity. Parsing and reserialising equivalent JSON can produce different bytes and therefore a different SHA-256 digest.

What cellc Does

The LS-IDL command group is:

cellc artifact ls-idl <validate|bind|bundle|fetch>

It provides four bounded operations:

  • validate checks the supported LS-IDL 0.1 schema and reports the SHA-256 of
    the exact file bytes;
  • bind appends that 32-byte digest to a CKB executable;
  • bundle creates the immutable Registry bundle and Artifact.toml; and
  • fetch resolves an IDL by a chain-verified CKB Script identity, validates
    the Registry response contract, and writes the exact response bytes.

The checked-in walkthrough inputs are under examples/registry_ls_idl.

Prerequisites

Prepare all of the following before publishing:

  • CellScript 0.24 cellc;
  • the original LS-IDL 0.1 idl.json;
  • a clean, unbound CKB Lock Script executable;
  • the source file to include in the Registry bundle;
  • an immutable 40- or 64-hex source revision;
  • the intended CKB hash_type and dep_type; and
  • a compatible CKB wallet for Registry writes and deployment.

Validation and lookup are public read operations and do not require a wallet.
Wallet authorisation is required when publishing or attaching deployment
evidence.

1. Prepare the IDL Document

A small document looks like this:

{
  "idl_version": "0.1",
  "name": "demo_lock",
  "witness": [
    {
      "name": "signature",
      "type": "secp256k1_sig",
      "required": true,
      "description": "Recoverable CKB secp256k1 signature"
    },
    {
      "name": "nonce",
      "type": "uint64",
      "required": true
    },
    {
      "name": "memo",
      "type": "bytes",
      "required": false
    }
  ]
}

The Registry profile accepts a non-empty JSON object no larger than 256 KiB.
witness is required and may contain at most 256 fields. Field names must be
unique.

The implemented field types are:

LS-IDL type Linear encoding
uint8 one unsigned byte
uint32 four-byte little-endian unsigned integer
uint64 eight-byte little-endian unsigned integer
secp256k1_sig 65 bytes
secp256k1_pubkey 33 bytes
schnorr_sig 64 bytes
bytes four-byte little-endian length followed by the payload

2. Validate the Exact Bytes

Run validation before touching the executable:

cellc artifact ls-idl validate --idl idl.json --json

The JSON result includes:

{
  "status": "valid",
  "format": "ls-idl",
  "format_version": "0.1",
  "sha256": "<SHA-256 of the exact idl.json bytes>",
  "executable_suffix_bound": false
}

This step rejects malformed JSON, unknown keys, unsupported types, duplicate field names, missing required field properties, and profile budget violations.

It does not modify the IDL or executable.

Treat the reported digest as part of the release identity. If idl.json changes afterward, repeat validation and binding.

3. Bind the IDL to the Executable

Always bind from a clean build output:

cellc artifact ls-idl bind \
  --idl idl.json \
  --executable build/demo-lock \
  --output build/demo-lock.ls-idl \
  --json

bind validates the IDL, computes SHA-256(raw idl.json bytes), and writes:

build/demo-lock bytes || 32-byte IDL digest

Verify the final pair explicitly:

cellc artifact ls-idl validate \
  --idl idl.json \
  --executable build/demo-lock.ls-idl \
  --json

The result now reports executable_suffix_bound: true. A suffix mismatch is a
hard error.

4. Create the Registry Bundle

Create the immutable bundle only from the bound executable:

cellc artifact ls-idl bundle \
  --idl idl.json \
  --executable build/demo-lock.ls-idl \
  --source src/lib.rs \
  --namespace example \
  --name demo-lock \
  --release 0.1.0 \
  --language rust \
  --hash-type data1 \
  --dep-type code \
  --toolchain 'rustc 1.97.1 + ckb-std' \
  --source-revision <40-or-64-hex-immutable-revision> \
  --output artifact.bundle.json \
  --artifact-manifest-output Artifact.toml \
  --json

The accepted --language values are cellscript, rust, c, javascript, and other. --hash-type defaults to data1; --dep-type defaults to code. State both explicitly in release automation so deployment evidence cannot inherit an accidental default.

The command writes two files:

  • artifact.bundle.json contains exactly one source, executable, and abi
    object as Base64-encoded bytes;
  • Artifact.toml names the deployable_contract release and points to that
    bundle.

It also reports four different identities:

Output field Algorithm and object Purpose
source_hash CKB Blake2b-256 of the source object immutable source-object identity
artifact_hash CKB Blake2b-256 of the bound executable CKB executable/data identity
abi_hash CKB Blake2b-256 of the raw IDL object Registry ABI object identity
idl_sha256 SHA-256 of the raw IDL object LS-IDL executable-suffix commitment

5. Dry-Run and Publish

Validate the generated bundle locally before any Registry write:

cellc publish \
  --artifact-manifest Artifact.toml \
  --dry-run \
  --json

The dry-run rechecks the coordinate, object roles, size limits, profile contract, raw IDL schema and hashes, and executable suffix. It does not upload anything.

For a first production publish, let cellc create a scoped delegated key and open the wallet authorisation flow:

cellc publish \
  --artifact-manifest Artifact.toml \
  --authorise \
  --json

For the Pudge Testnet Registry, select its API explicitly:

cellc publish \
  --artifact-manifest Artifact.toml \
  --authorise \
  --api-url https://api.testnet.registry.cellscript.dev \
  --json

After admission, the release starts with independent states similar to:

verification_status = pending
deployment_status   = undeployed
availability_status = active

The Registry worker must accept the immutable bundle and promote its integrity evidence before deployment evidence can be attached. A hash_bound result is an identity/integrity statement, not a security review.

6. Deploy the Bound Artifact and Record Evidence

Deploy build/demo-lock.ls-idl, not build/demo-lock. Deployment transaction
construction, capacity, fees, witnesses, signing, and broadcast remain in the
external CKB builder and wallet.

After the deployment transaction is committed, attach its OutPoint to the published release. First authorise a delegated key with the exact deployment scope. The principal_id is the normalized identity binding derived from the
connected signer, not the displayed CKB address. Choose joyid_ckb or
ckb_secp256k1 for --principal-type:

cellc auth capability create \
  --principal-type <principal-type> \
  --principal-id <normalized-wallet-principal-id> \
  --scope deployment:example/demo-lock \
  --expires 90d \
  --json > deployment-capability.json

Sign the payload with the matching CKB wallet, save the wallet result as deployment-wallet-signature.json, and submit it:

cellc auth capability submit \
  --payload deployment-capability.json \
  --wallet-signature deployment-wallet-signature.json \
  --json

create stores the generated delegated private key in the local OS keychain; submit returns its cap_... key ID after the Registry accepts the wallet-rooted grant. For Testnet, add --registry-origin https://api.testnet.registry.cellscript.dev to create and --api-url https://api.testnet.registry.cellscript.dev to submit.

The manual flow may request publish: and deployment: together when one key must perform both operations, but neither scope implies the other. This tutorial keeps them separate so a deployment key cannot publish a new release.

Then record the Testnet deployment:

cellc artifact record-deployment example/[email protected] \
  --network testnet \
  --api-url https://api.testnet.registry.cellscript.dev \
  --code-hash 0x<64-hex> \
  --hash-type data1 \
  --dep-type code \
  --tx-hash 0x<64-hex-deployment-transaction-hash> \
  --index 0 \
  --capability-key-id cap_<deployment-key-id> \
  --json

For production, use --network mainnet and the default production Registry origin. The command signs the deployment record with the delegated key, while the Registry verifies the configured RPC network, live code Cell, committe creation transaction, confirmations, artifact data hash, Script identity, and declared deployment mode.

The immutable profile and the evidence command must agree on hash_type and dep_type:

  • for data, data1, or data2, code_hash identifies the bound code Cell
    data; and
  • for type, code_hash is the code Cell Type Script hash. Later LS-IDL
    lookup also needs the current code Cell data_hash to disambiguate the
    executable bytes.

Do not describe a locally generated deployment payload as chain evidence. The Registry lookup becomes available only after the release is active, public, and backed by accepted chain-verified deployment evidence.

7. Fetch the Exact IDL by Script Identity

For a mainnet data1 Script:

cellc artifact ls-idl fetch \
  --code-hash 0x<64-hex> \
  --hash-type data1 \
  --network mainnet \
  --output fetched-idl.json \
  --json

For Testnet, use the matching Registry API:

cellc artifact ls-idl fetch \
  --code-hash 0x<64-hex> \
  --hash-type data1 \
  --network testnet \
  --api-url https://api.testnet.registry.cellscript.dev \
  --output fetched-idl.json \
  --json

For a Type Hash deployment, add the live code Cell data hash:

cellc artifact ls-idl fetch \
  --code-hash 0x<64-hex-type-script-hash> \
  --hash-type type \
  --data-hash 0x<64-hex-code-cell-data-hash> \
  --network mainnet \
  --output fetched-idl.json \
  --json

fetch refuses to overwrite an existing output unless --force is explicit.

Before writing, it requires the LS-IDL content type and schema-and-suffix-bound verification header, enforces the 256 KiB limit, validates the LS-IDL schema, hashes the response body directly, and compares that digest with the Registry header.

The public browser flows expose the same lookup for Mainnet and the isolated Pudge Testnet.

Lookup and download are read-only; connecting a wallet is needed only for Registry writes.

Common Failures

The executable suffix does not match

The IDL was changed or reformatted after binding, or the unbound executable was selected. Return to the clean executable and bind the final IDL bytes again.

cellc refuses to overwrite a file

This is intentional. Use a new output path, or pass --force only after checking the exact target.

Type Hash lookup requires --data-hash

A Type Script hash may identify more than one executable data revision. Supply the current code Cell data hash; the Registry returns 409 rather than choosing an ambiguous deployment.

Fetch returns not found

Check the Registry environment, network, code_hash, hash_type, and optional data_hash. A published bundle is not enough: the release must also be active, public, and chain-verified on the requested network.

Publish succeeds but lookup is not ready

Publication, verification, and deployment are separate states. Wait for bundle verification, deploy the bound artifact, and record the committed deployment OutPoint before expecting Script-identity lookup to succeed.

5 Likes

This flow is so clean. The number of requirements of a registry that are here, that I didn’t think of myself is crazy. This passes for standard an entire community could use

Well done, Ser

6 Likes

This is fire :fire:

1 Like