Skip to content

[Language design] Add explicit typed openings for digest-committed substate #13

Description

@a19q3

Summary

Design an explicit, typed abstraction for opening, reading, updating, and recommitting digest-backed substate while keeping the parent Cell schema stable.

The target use case is an extensible capsule or plugin state: a shared base schema stores a commitment, while a concrete implementation supplies a typed preimage through a versioned witness, proves that the preimage matches the stored commitment, applies checked logic, and commits the successor substate.

Argent's virtual-slot state expansion demonstrates this application pattern. CellScript should preserve its stronger explicit-schema rule: the commitment field and witness opening must remain visible in source, ABI, ProofPlan, and metadata. The compiler must not inject hidden persistent fields.

Motivation

A stable external state shape is useful when independently developed implementations need private or implementation-specific memory:

Stable public capsule:
- controller identity
- implementation/interface handle
- strategy commitment
- energy / nonce / public counters

Implementation-private committed state:
- strategy parameters
- model memory
- plugin configuration
- bounded auxiliary state

Today an author can manually hash witness bytes and compare them to a Hash field, but the compiler cannot prove that:

  • both sides use the same canonical schema;
  • the hash has the correct domain separation;
  • the preimage is decoded exactly once;
  • every read comes from an authenticated opening;
  • every mutation is reflected in the successor commitment;
  • metadata and builders use the same witness codec;
  • the checker can link source-level commitment semantics to machine code.

Explicit source model

The final syntax requires an RFC. A strawman illustrates the intended visibility:

struct AgentCapsule {
    controller: Address
    strategy_commitment: Commitment<StrategyState, CellScriptPackedHashV0>
    energy: u64
}

struct StrategyState {
    hunger: u64
    mood: u64
}

action step(
    before: AgentCapsule,
    witness opening: Opening<StrategyState>
) -> after: AgentCapsule {
    transition before -> after

    verification
        let strategy =
            open(before.strategy_commitment, opening)

        let next_strategy = StrategyState {
            hunger: strategy.hunger + 1,
            mood: strategy.mood
        }

        require after.strategy_commitment ==
            commit<CellScriptPackedHashV0>(next_strategy)
        require after.energy == before.energy - 1
}

The parent schema stores only the commitment. The opening is transaction evidence, not persistent hidden state.

flowchart LR
    CELL["Input Cell data<br/>typed base schema"] --> DIGEST["Stored commitment"]
    WIT["WitnessArgs opening<br/>versioned Molecule T"] --> DECODE["Decode T once"]
    DECODE --> HASH["Domain-separated canonical hash"]
    DIGEST --> MATCH{"hash == commitment?"}
    HASH --> MATCH
    MATCH -- "no" --> REJECT["Stable opening error"]
    MATCH -- "yes" --> VALUE["Authenticated typed T"]
    VALUE --> UPDATE["Compute next T"]
    UPDATE --> COMMIT["Commit canonical next T"]
    COMMIT --> OUT["Constrain output commitment"]
Loading

Required semantic properties

Typed commitment identity

Commitment<T, H, Domain> must include, directly or through canonical metadata identity:

  • source type identity of T;
  • registered serialization profile, type identity, and profile version;
  • hash algorithm/profile;
  • domain-separation identifier;
  • fixed/dynamic size bounds;
  • compatibility identity.

A commitment to StrategyStateV1 must not be interchangeable with the same bytes interpreted as another type.

Authenticated opening

An Opening<T> must:

  • come from an explicit witness/source qualifier;
  • use a versioned canonical codec;
  • be decoded once with strict bounds;
  • be hashed over the exact canonical bytes;
  • become a typed value only after the digest comparison succeeds;
  • never escape as an authenticated value on a failure path.

Successor correspondence

When source semantics claim an updated committed substate:

  • the successor value must be evaluated once;
  • the output commitment must be derived from the exact typed successor;
  • no path may preserve the old digest while using mutated private state;
  • no path may construct a digest from noncanonical bytes;
  • linear Cell effects must remain explicit on the parent Cell.

No behavioral inference

Matching a base capsule or interface shape does not prove that an external implementation follows a desired behavior. This feature authenticates state openings and updates; it does not establish semantic equivalence between plugins.

Hash and codec requirements

The first fixed-width version should build on the existing CellScriptPackedHashV0\0 preimage contract used by hash_blake2b_packed instead of silently inventing a second encoding:

CellScriptPackedHashV0\0 || type_name || 0 || width_u32_le || exact packed bytes

The current implementation is visible at

if func == "__ckb_hash_blake2b_packed" {
let Some(arg) = args.first() else {
self.emit("# cellscript abi: fail closed because hash_blake2b_packed is missing input");
self.emit_fail(CellScriptRuntimeError::PackedHashPreimageMaterializationUnresolved);
return Ok(true);
};
let Some(width) = operand_fixed_byte_width(arg).or_else(|| match arg {
IrOperand::Var(var) => self.fixed_byte_like_width(&var.ty),
_ => None,
}) else {
self.emit("# cellscript abi: fail closed because hash_blake2b_packed input has no static packed width");
self.emit_fail(CellScriptRuntimeError::PackedHashPreimageMaterializationUnresolved);
return Ok(true);
};
let Some(source) = self.expected_fixed_byte_source(arg, width) else {
self.emit("# cellscript abi: fail closed because hash_blake2b_packed input is not materializable");
self.emit_fail(CellScriptRuntimeError::PackedHashPreimageMaterializationUnresolved);
return Ok(true);
};
let type_name = match arg {
IrOperand::Var(var) => named_type_name(&var.ty).map(str::to_string).unwrap_or_else(|| aggregate_type_label(&var.ty)),
IrOperand::Const(_) => "const".to_string(),
};
let mut header = b"CellScriptPackedHashV0\0".to_vec();
header.extend_from_slice(type_name.as_bytes());
header.push(0);
header.extend_from_slice(&(width as u32).to_le_bytes());
let total_width = header.len() + width;
if total_width > RUNTIME_SCRATCH_BUFFER_SIZE {
self.emit("# cellscript abi: fail closed because hash_blake2b_packed preimage exceeds scratch buffer");
self.emit_fail(CellScriptRuntimeError::PackedHashPreimageMaterializationUnresolved);
return Ok(true);
}
let buffer_offset = self.runtime_scratch_buffer_offset();
for (index, byte) in header.iter().enumerate() {
self.emit(format!("li t0, {}", byte));
self.emit_stack_store_byte("t0", buffer_offset + index);
}
self.emit_prepare_fixed_byte_source(&source, width, "hash_blake2b_packed input");
if !self.emit_fixed_byte_source_pointer_or_const_to("a0", &source) {
self.emit("# cellscript abi: fail closed because hash_blake2b_packed input pointer is not materializable");
self.emit_fail(CellScriptRuntimeError::PackedHashPreimageMaterializationUnresolved);
return Ok(true);
}
self.emit_sp_addi("a1", buffer_offset + header.len());
self.emit(format!("li a2, {}", width));
self.emit("call __cellscript_memcpy_fixed");
self.emit_sp_addi("a0", buffer_offset);
self.emit(format!("li a1, {}", total_width));
self.emit_sp_addi("a2", dest_offset);
self.emit("call __ckb_hash_blake2b_var");
self.emit_return_on_syscall_error(CellScriptRuntimeError::SyscallFailed);
. Phase 0 must decide whether its current type-name component is stable and canonical enough for a public commitment identity or whether a registered successor profile is required. In either case, the profile must bind CKB Blake2b-256, type identity, width, exact packed bytes, explicit preimage limits, metadata identity, and deterministic vectors shared by compiler, builder, simulator, CKB-VM, and checker.

Do not describe the fixed-width packed representation as Molecule. Bounded dynamic Molecule values require a separate registered codec/profile after the strict offset/count work in #7 and #8. Additional hash algorithms likewise require separate registered profiles; do not accept a free-form hash function parameter.

Representation options to decide

The RFC must choose and document:

Question Required decision
commitment bytes raw 32 bytes versus a nominal fixed-width wrapper
domain separation type-derived, declaration-derived, or explicit registered domain
witness placement input_type, output_type, lock, or declared source
dynamic fields initial fixed-width-only boundary versus bounded Molecule dynamics
nested commitments reject initially or define depth and size budgets
optional openings explicit enum/Option semantics; no empty-witness ambiguity
unchanged state require opening, or permit digest-preserving paths that never read private fields
package upgrade how compatible schema evolution affects commitment identity

Implementation phases

Phase 0 — RFC and threat model

Cover:

  • type/codec/hash/domain identity;
  • witness source and size bounds;
  • opening validity and evaluation order;
  • unchanged versus updated paths;
  • nested/dynamic limits;
  • interface and package compatibility;
  • error codes;
  • ProofPlan and checker obligations.

Phase 1 — fixed-width committed values

  • implement Commitment<T> and Opening<T> for fixed-width non-Cell values;
  • add canonical commit/open operations;
  • retain all source qualifiers and identities in typed semantics;
  • generate exact CKB-VM hash and compare paths;
  • add builder witness encoding.

Phase 2 — successor updates

  • require explicit output commitment correspondence;
  • add one-to-one source/update/output ProofPlan records;
  • reject stale-digest and noncanonical-reencoding paths;
  • add standalone checker mutations for missing opening and recommit blocks.

Phase 3 — bounded dynamic values

Only after fixed-width stabilization:

  • permit selected bounded Molecule dynamic fields;
  • enforce exact byte/count limits;
  • extend cycle, stack, witness, and transaction-size evidence;
  • keep nested or recursive shapes fail-closed unless separately specified.

Adversarial matrix

Case Expected
valid canonical opening pass
one-byte preimage mutation stable commitment mismatch
valid bytes under wrong type identity reject
noncanonical bytes for the selected profile, including Molecule once a dynamic profile is admitted reject
wrong hash/domain profile reject
truncated or oversized witness stable codec/size error
read private field before authentication compile/checker rejection
update private value but preserve old digest reject
compute correct digest but bind it to wrong output role reject
evaluate state-producing expression twice with different reads prevented by lowering and tested
remove hash comparison from ELF standalone checker rejects
preserve digest without opening and without reading private state allowed only if explicitly specified
nested opening beyond configured depth production rejection

Completion criteria

  • An accepted RFC fixes type, codec, hash, domain, source, and update semantics.
  • Commitment/opening types are explicit in source and public interfaces.
  • Parent Cell schemas contain only author-declared fields; no hidden persistent field is injected.
  • Authenticated openings cannot be used before verification or escape failure paths.
  • Successor commitments are tied to exactly one evaluated typed successor.
  • Typed semantics, ProofPlan, lowering records, source maps, and checker preserve the complete contract.
  • Compiler, builder, simulator, CKB-VM, and independent checker share exact hash/codec vectors.
  • Fixed-width positive and adversarial matrices pass before dynamic values are admitted.
  • Cycle, memory, witness, transaction-size, and capacity bounds are recorded.
  • LSP, formatter, VS Code, Playground summary, docs, and generated builders agree.
  • Independent security review covers codec ambiguity, domain separation, stale commitments, and output correspondence.

Non-goals

  • inheritance;
  • hidden compiler-owned persistent state;
  • arbitrary opaque witness bytes;
  • generic recursive/dynamic allocation;
  • behavioral equivalence of plugin implementations;
  • automatically authorizing a runtime-selected Script;
  • placing Cell-backed resources inside ordinary commitment values;
  • replacing the explicit parent Cell lifecycle.

Relationship to other work

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions