Skip to content

[Language/stdlib] Add typed CKB temporal and Since domains #12

Closed

Description

@a19q3

Summary

Replace ambiguous raw-u64 time values at the authoring boundary with explicit CKB temporal domain types.

CellScript currently returns u64 for the HeaderDep epoch number, epoch start block number, epoch length, input since, and absolute/relative epoch-since constructors. The runtime checks are bounded and fail-closed, but the type system cannot prevent mixing values with different units or comparing an encoded since bitfield to a plain epoch number.

Argent's temporal-value work demonstrates the value of preserving temporal identity in artifacts even when the runtime representation remains integer-based. CellScript should define a CKB-specific, wire-compatible version rather than a generic temporal wrapper.

Problem examples

All of the following values are currently represented as u64:

HeaderDep epoch number
HeaderDep epoch start block number
HeaderDep epoch length
encoded input since
absolute epoch since
relative epoch since
application epoch deadline
application epoch duration

This makes invalid combinations type-correct:

let epoch = env::current_timepoint()
let encoded_since = ckb::input_since(source::input(0))

// Numerically valid u64 operations, semantically invalid domains:
require encoded_since >= epoch
let deadline = encoded_since + epoch

The current implementation also names an epoch-number read current_timepoint, which is easy to misread as a Unix timestamp.

Proposed type families

The exact surface requires an RFC, but the semantic domains should include:

Type Meaning Candidate runtime representation
EpochNumber whole CKB epoch number checked u64
BlockNumber absolute block number checked u64
Timestamp CKB timestamp metric checked u64
EpochLength blocks in one epoch nonzero checked u64
EpochFraction CKB epoch number/index/length tuple canonical fixed fields or encoded u64
Since<Absolute, BlockNumber> absolute block-number constraint canonical encoded u64
Since<Absolute, EpochFraction> absolute epoch-fraction constraint canonical encoded u64
Since<Absolute, Timestamp> absolute timestamp constraint canonical encoded u64
Since<Relative, BlockNumber> relative block-number constraint canonical encoded u64
Since<Relative, EpochFraction> relative epoch-fraction constraint canonical encoded u64
Since<Relative, Timestamp> relative timestamp constraint canonical encoded u64
DecodedSince tagged union returned by decoding raw input since one of the six validated variants
EpochDuration application duration in whole epochs checked u64

The exact generic spelling is an RFC decision. The invariant is not: both the relative/absolute mode and the block/epoch/timestamp metric must remain in the type identity. A wrapper that distinguishes only AbsoluteSince from RelativeSince is insufficient.

flowchart TB
    H["HeaderDep fields"] --> EN["EpochNumber"]
    H --> BN["BlockNumber"]
    H --> EL["EpochLength"]

    EN --> EWF["EpochNumberWithFraction"]
    EL --> EWF

    EWF --> SAE["Since<Absolute, EpochFraction>"]
    EWF --> SRE["Since<Relative, EpochFraction>"]
    BN --> SAB["Since<Absolute, BlockNumber>"]
    BN --> SRB["Since<Relative, BlockNumber>"]
    TS["Timestamp"] --> SAT["Since<Absolute, Timestamp>"]
    TS --> SRT["Since<Relative, Timestamp>"]

    RAW["Raw u64"] -. "explicit checked conversion only" .-> EN
    RAW -. "decode flags + metric + payload" .-> DS["DecodedSince"]
Loading

Required type rules

Allowed by default

  • compare two values of the same temporal domain;
  • add/subtract a matching duration with overflow/underflow checks;
  • construct EpochNumberWithFraction from validated number/index/length;
  • encode a validated epoch fraction into absolute or relative Since;
  • decode an input since into a tagged validated representation;
  • serialize to the existing fixed-width ABI where wire compatibility allows.

Rejected without explicit conversion

  • EpochNumber versus BlockNumber;
  • epoch values versus encoded Since;
  • absolute versus relative Since;
  • block-number versus epoch-fraction versus timestamp Since;
  • temporal values versus arbitrary u64;
  • application durations versus absolute timepoints;
  • malformed epoch fractions;
  • arithmetic that discards metric or relative flags.

Explicit escape hatch

Low-level protocol work may need raw bits. Provide named operations such as:

Since::from_raw_checked(u64) -> DecodedSince
Since<Mode, Metric>::to_raw()
EpochNumber::from_u64_checked(u64)
EpochNumber::to_u64()

The unsafe-looking direction must be explicit in source and typed-semantics metadata. No implicit widening or assignment conversion should cross these domains.

API migration sketch

A candidate additive first step:

let epoch: EpochNumber = env::current_epoch_number()
let start: BlockNumber = ckb::header_epoch_start_block_number(header)
let length: EpochLength = ckb::header_epoch_length(header)

let maturity: Since<Relative, EpochFraction> =
    ckb::since_epoch_relative(number, index, length)

let observed: DecodedSince = ckb::input_since(input)
require observed.satisfies(maturity)

The existing env::current_timepoint() -> u64 should remain only under the existing edition contract, emit a targeted migration warning, and migrate mechanically to the precisely named API. Do not silently change its return type within an edition.

Artifact and interface requirements

Temporal identity must survive every compiler layer:

flowchart LR
    SRC["Typed source"] --> AST["Temporal AST type"]
    AST --> IR["Distinct IR type / checked ops"]
    IR --> TS["typed-semantics record"]
    IR --> ABI["Fixed wire representation"]
    TS --> CHECK["Standalone checker"]
    ABI --> VM["CKB-VM execution"]
Loading

The public package interface must record:

  • semantic temporal type;
  • exact serialized representation;
  • constructor/decoder version;
  • Since metric and relative/absolute policy;
  • target/profile identity;
  • migration behavior.

A type alias to u64 is insufficient because it would erase the safety property before IR.

Implementation phases

Phase 0 — inventory and RFC

  • inventory every stdlib/source/IR/metadata path that currently uses a temporal u64;
  • define the type domains and legal operations;
  • define ABI and interface compatibility;
  • define edition migration and diagnostics;
  • align with official CKB Since and epoch-fraction semantics.

Phase 1 — additive typed APIs

  • add typed constructors, readers, decoders, comparisons, and checked arithmetic;
  • preserve old APIs with warnings;
  • add typed-semantics and interface records;
  • add formatter, LSP, docs, and Playground support.

Phase 2 — migration

  • provide a mechanical migration command or code action;
  • migrate bundled examples and package fixtures;
  • keep old-edition dependencies interoperable;
  • reject implicit cross-domain arithmetic in the new edition.

Phase 3 — stabilization

  • mutation-test Since flag/metric validation;
  • add exact CKB-VM vectors for valid and malformed encodings;
  • document compatibility and wire identity;
  • stabilize only after real timelock, DAO, vesting, and atomic-swap fixtures migrate.

Acceptance matrix

Case Expected
compare two EpochNumber values pass
add EpochDuration to EpochNumber pass with overflow guard
compare EpochNumber and BlockNumber type error
compare encoded Since and epoch type error
construct fraction with index >= length stable error
construct fraction with zero/overflowing length stable error
decode unknown Since flags stable runtime error
mix absolute and relative Since type error unless explicitly normalized by an accepted operation
round-trip valid CKB Since vectors exact match
compile old-edition current_timepoint source preserved behavior plus migration policy
cross-package interface changes raw u64 to typed domain correctly classified as compatible or breaking according to the accepted edition/ABI policy

Completion criteria

  • An accepted RFC defines every temporal domain and operation.
  • Official CKB Since and epoch semantics are cited and pinned with vectors.
  • Temporal identity survives AST, type checking, IR, typed semantics, interfaces, ABI, lowering, and checker.
  • The wire representation remains deterministic and versioned.
  • New-edition code cannot implicitly mix temporal domains with u64.
  • Existing-edition behavior is preserved with a mechanical migration path.
  • Timelock, DAO, vesting, NFT expiry, governance, and atomic-swap examples use the typed API.
  • Malformed flags, fractions, and arithmetic boundaries have CKB-VM negative tests.
  • LSP, formatter, VS Code, Playground, docs, and generated builders agree.
  • dev, ci, and backend gates pass.

Non-goals

  • inventing wall-clock semantics that CKB does not expose;
  • treating a HeaderDep epoch number as a Unix timestamp;
  • implicit conversion from arbitrary integers;
  • changing existing edition semantics in place;
  • generic dimensional-analysis or units-of-measure support;
  • hiding CKB Since flags behind unchecked arithmetic.

References

Activity

  1. a19q3 commented on Aug 27, 2026

    @a19q3
    CollaboratorAuthor

    Research update: the gap is nominal typing, not an absent runtime implementation

    Comment audit

    There were no prior comments on this issue at the time of review.

    Current CellScript behavior

    The audit confirms the issue's premise, but also narrows it:

    • the stdlib exposes epoch fields, input since, and epoch Since constructors as u64:
      StdFunction { name: "env_current_timepoint".to_string(), params: vec![], return_type: Some(IrType::U64) },
      StdFunction { name: "ckb_header_epoch_number".to_string(), params: vec![], return_type: Some(IrType::U64) },
      StdFunction { name: "ckb_header_epoch_start_block_number".to_string(), params: vec![], return_type: Some(IrType::U64) },
      StdFunction { name: "ckb_header_epoch_length".to_string(), params: vec![], return_type: Some(IrType::U64) },
      StdFunction { name: "ckb_input_since".to_string(), params: vec![], return_type: Some(IrType::U64) },
      StdFunction {
      name: "ckb_since_epoch_absolute".to_string(),
      params: vec![
      ("number".to_string(), IrType::U64),
      ("index".to_string(), IrType::U64),
      ("length".to_string(), IrType::U64),
      ],
      return_type: Some(IrType::U64),
      },
      StdFunction {
      name: "ckb_since_epoch_relative".to_string(),
      params: vec![
      ("number".to_string(), IrType::U64),
      ("index".to_string(), IrType::U64),
      ("length".to_string(), IrType::U64),
      ],
      return_type: Some(IrType::U64),
      },
    • IR lowering preserves them as IrType::U64:

      CellScript/src/ir/mod.rs

      Lines 5658 to 5684 in 8ae6dc4

      "ckb::input_since" if call.args.is_empty() => {
      let dest = self.new_var("ckb_input_since", IrType::U64);
      self.block_mut(blocks, current).instructions.push(IrInstruction::Call {
      dest: Some(dest.clone()),
      func: "__ckb_input_since".to_string(),
      args: Vec::new(),
      });
      Some(LoweredExpr { operand: IrOperand::Var(dest), current: Some(current) })
      }
      "ckb::since_epoch_absolute" if call.args.len() == 3 => self.lower_simple_runtime_call(
      "__ckb_since_epoch_absolute",
      "ckb_since_epoch_absolute",
      IrType::U64,
      &call.args,
      current,
      blocks,
      vars,
      ),
      "ckb::since_epoch_relative" if call.args.len() == 3 => self.lower_simple_runtime_call(
      "__ckb_since_epoch_relative",
      "ckb_since_epoch_relative",
      IrType::U64,
      &call.args,
      current,
      blocks,
      vars,
      ),
    • the epoch Since runtime constructor already validates number/index/length bounds, length > 0, and index < length before encoding RFC0017 flags:
      fn emit_runtime_ckb_since_epoch_helper(&mut self, symbol: &str, relative: bool, detail: &str, enabled: bool) {
      self.emit_global(symbol);
      self.emit_label(symbol);
      self.emit(format!("# cellscript abi: {}", detail));
      self.emit("# cellscript abi: args a0=number(<2^24), a1=index(<2^16), a2=length(<2^16); requires length>0 and index<length");
      self.emit("# cellscript abi: encodes CKB RFC0017 EpochNumberWithFraction as number | index<<24 | length<<40");
      if !enabled {
      self.emit("li a0, 0");
      self.emit(format!("li a1, {}", CellScriptRuntimeError::SyscallFailed.code()));
      self.emit("ret");
      return;
      }
      let malformed = self.fresh_label("ckb_since_epoch_malformed");
      let done = self.fresh_label("ckb_since_epoch_done");
      self.emit(format!("li t0, {}", CKB_EPOCH_NUMBER_BOUND));
      self.emit("sltu t1, a0, t0");
      self.emit(format!("beqz t1, {}", malformed));
      self.emit(format!("li t0, {}", CKB_EPOCH_FRACTION_BOUND));
      self.emit("sltu t1, a1, t0");
      self.emit(format!("beqz t1, {}", malformed));
      self.emit("sltu t1, a2, t0");
      self.emit(format!("beqz t1, {}", malformed));
      self.emit(format!("beqz a2, {}", malformed));
      self.emit("sltu t1, a1, a2");
      self.emit(format!("beqz t1, {}", malformed));
      self.emit("addi t2, a0, 0");
      self.emit("slli t0, a1, 24");
      self.emit("or t2, t2, t0");
      self.emit("slli t0, a2, 40");
      self.emit("or t2, t2, t0");
      self.emit(format!("li t0, {}", CKB_SINCE_EPOCH_NUMBER_WITH_FRACTION_FLAG));
      self.emit("or t2, t2, t0");
      if relative {
      self.emit("li t0, 1");
      self.emit("slli t0, t0, 63");
      self.emit("or t2, t2, t0");
      }
      self.emit("addi a0, t2, 0");
      self.emit("li a1, 0");
      self.emit(format!("j {}", done));
      self.emit_label(&malformed);
      self.emit("li a0, 0");
      self.emit(format!("li a1, {}", CellScriptRuntimeError::CkbSinceMalformed.code()));
      self.emit_label(&done);
      self.emit("ret");
    • malformed encodings already have a stable CkbSinceMalformed runtime error.

    Therefore this is not “Since is currently unchecked.” The missing property is that source types and IR cannot prevent a valid encoded Since from being compared or added to an unrelated u64 domain.

    One current API deserves migration priority

    The generic DAO helper dao_require_input_since_at_least loads an 8-byte input Since and performs a raw unsigned comparison:

    fn emit_runtime_dao_require_input_since_at_least_helper(&mut self, enabled: bool) {
    self.emit_global("__dao_require_input_since_at_least");
    self.emit_label("__dao_require_input_since_at_least");
    self.emit("# cellscript abi: DAO input since lower-bound requirement");
    self.emit("# cellscript abi: args a0=input SourceView, a1=required_since; enforces loaded_since >= required_since");
    if !enabled {
    self.emit(format!("li a0, {}", CellScriptRuntimeError::SyscallFailed.code()));
    self.emit("ret");
    return;
    }
    const SIZE_OFFSET: usize = 8;
    const REQUIRED_SINCE_OFFSET: usize = 16;
    const SINCE_OFFSET: usize = 24;
    let invalid = self.fresh_label("dao_since_input_source_invalid");
    let failed = self.fresh_label("dao_since_load_failed");
    let malformed = self.fresh_label("dao_since_field_malformed");
    let immature = self.fresh_label("dao_since_immature");
    let done = self.fresh_label("dao_since_done");
    let abi = self.runtime_abi();
    self.emit("addi sp, sp, -48");
    self.emit("sd ra, 40(sp)");
    self.emit(format!("sd a1, {}(sp)", REQUIRED_SINCE_OFFSET));
    self.emit_decode_input_source_view_to_t1_t2(&invalid);
    self.emit("li t0, 8");
    self.emit(format!("sd t0, {}(sp)", SIZE_OFFSET));
    self.emit(format!("addi a0, sp, {}", SINCE_OFFSET));
    self.emit(format!("addi a1, sp, {}", SIZE_OFFSET));
    self.emit("li a2, 0");
    self.emit("addi a3, t1, 0");
    self.emit("addi a4, t2, 0");
    self.emit(format!("li a5, {}", CKB_INPUT_FIELD_SINCE));
    self.emit(format!("li a7, {}", abi.load_input_by_field));
    self.emit("ecall");
    self.emit(format!("bnez a0, {}", failed));
    self.emit(format!("ld t0, {}(sp)", SIZE_OFFSET));
    self.emit("li t1, 8");
    self.emit("sub t2, t0, t1");
    self.emit(format!("bnez t2, {}", malformed));
    self.emit(format!("ld t0, {}(sp)", SINCE_OFFSET));
    self.emit(format!("ld t1, {}(sp)", REQUIRED_SINCE_OFFSET));
    self.emit("sltu t2, t0, t1");
    self.emit(format!("bnez t2, {}", immature));
    self.emit("li a0, 0");
    self.emit(format!("j {}", done));
    self.emit_label(&invalid);
    self.emit(format!("li a0, {}", CellScriptRuntimeError::CkbSourceViewInvalid.code()));
    self.emit(format!("j {}", done));
    self.emit_label(&failed);
    self.emit(format!("li a0, {}", CellScriptRuntimeError::CellLoadFailed.code()));
    self.emit(format!("j {}", done));
    self.emit_label(&malformed);
    self.emit(format!("li a0, {}", CellScriptRuntimeError::DaoFieldMalformed.code()));
    self.emit(format!("j {}", done));
    self.emit_label(&immature);
    self.emit(format!("li a0, {}", CellScriptRuntimeError::DaoMaturityViolation.code()));
    self.emit_label(&done);
    self.emit("ld ra, 40(sp)");
    self.emit("addi sp, sp, 48");
    self.emit("ret");

    A raw integer ordering is only meaningful after both operands are known to use the same relative/absolute mode, metric, and valid encoding. CellScript also has a more specific relative-epoch helper that validates flags and compares epoch fractions. The typed migration should prefer such domain-specific operations and deprecate or strictly constrain the raw lower-bound helper.

    This is a type-safety and API-footgun finding, not by itself proof that an existing caller is exploitable.

    Exact CKB domains

    RFC 17 is the normative source:

    https://github.com/nervosnetwork/rfcs/blob/master/rfcs/0017-tx-valid-since/0017-tx-valid-since.md

    The encoded u64 contains independent dimensions:

    • disabled zero versus enabled;
    • absolute versus relative;
    • metric: block number, epoch number with fraction, or timestamp;
    • reserved-bit validity;
    • metric-specific payload;
    • for epoch fractions, number/index/length validity and comparison semantics.

    The source model should preserve these dimensions. A single nominal AbsoluteSince and RelativeSince type is still too weak if block, epoch, and timestamp metrics can be mixed.

    A robust internal family is conceptually:

    • Since<Absolute, BlockNumber>
    • Since<Absolute, EpochFraction>
    • Since<Absolute, Timestamp>
    • Since<Relative, BlockNumber>
    • Since<Relative, EpochFraction>
    • Since<Relative, Timestamp>

    The surface syntax may use an enum/tagged value instead of generics. The non-negotiable part is that comparison and arithmetic dispatch on validated domains.

    Recommended operations

    Allow:

    • checked construction from domain payloads;
    • checked decode from raw u64 into a tagged Since value;
    • exact encode back to u64;
    • same-domain comparison;
    • absolute timepoint plus matching relative duration;
    • metric-specific satisfaction checks;
    • normalization exactly as RFC 17 specifies.

    Reject:

    • raw relational operators between encoded Since and u64;
    • absolute versus relative ordering;
    • epoch versus block/timestamp ordering;
    • arithmetic that can change flags or metric bits;
    • arbitrary construction from u64 without validation;
    • reserved or invalid metric flags.

    A low-level to_raw operation can exist, but using its result should visibly leave the typed temporal domain.

    ABI and compatibility

    The implementation does not need a new CKB wire encoding. It can preserve u64 at the syscall and entry ABI boundary while keeping nominal types in:

    • AST/type checking;
    • IR operation kinds;
    • typed semantics;
    • public interfaces;
    • ProofPlan/checker records;
    • builder type generation.

    Changing an exported source signature from u64 to a temporal nominal type should be treated as a source API change even if the runtime ABI remains eight bytes. The six-dimensional interface report is well suited to say “source breaking, runtime representation unchanged.”

    Edition migration

    Recommended sequence:

    1. Add precisely named typed APIs in the current edition.
    2. Emit a focused warning for ambiguous current_timepoint and raw Since comparisons.
    3. Add formatter/LSP code actions that select the intended metric and mode.
    4. Preserve old-edition runtime behavior.
    5. In a later edition, reject implicit u64/temporal mixing.
    6. Keep explicit checked conversions for protocol-level code.

    Do not silently change existing return types inside Edition 2026.

    Required vectors

    Share exact official vectors across type checker, constant folding, runtime lowering, builders, simulator, CKB-VM, and independent checker:

    • zero/disabled;
    • absolute and relative block numbers;
    • epoch fractions at valid boundaries;
    • timestamps;
    • reserved bits set;
    • invalid metric;
    • zero/invalid length;
    • index equal to or greater than length;
    • overflow in absolute-plus-relative operations;
    • cross-metric and cross-mode comparisons.

    Argent comparison

    Argent PR #48 is a good signal that temporal identity should survive artifacts. It does not define CKB's Since flags or epoch fractions, so it should not determine the type family. Official CKB RFC 17 remains authoritative.

    Conclusion

    The issue is correctly scoped as an edition-aware type-safety improvement. The backend can reuse the existing validated u64 encoders and metric-specific runtime helpers. The critical implementation work is to stop erasing domain identity before IR and to migrate raw comparison APIs that are only conditionally meaningful.

  2. a19q3 commented on Sep 11, 2026

    @a19q3
    CollaboratorAuthor

    0.30 update: commit 8353723 adds an exact temporal CKB-VM scenario fixture. One independently validated typed Since/HeaderDep artifact now covers all four positive inventory rows (absolute timelock, relative timelock, vesting, and combined epoch/timestamp/block access) and all five adversarial rows (cross-domain, missing header, early release, epoch overflow, and one-past HeaderDep). The fixture pins artifact, lowering, source-map, verified-bundle, raw-transaction, and serialized-transaction identities. The temporal test passed twice; artifact-checker 36/36 and Clippy also passed. Remaining work is independent review and exact release-candidate evidence.

  3. a19q3 commented on Sep 12, 2026

    @a19q3
    CollaboratorAuthor

    0.30 closure update: the typed temporal/Since contract is complete for the accepted additive scope. Exact fixtures bind the four positive inventory rows and five adversarial rows to the checked artifact, lowering/source-map/bundle identities, and distinct raw/serialized CKB transactions. Absolute and relative Since, epoch/timestamp/block domains, HeaderDep access, overflow, missing-header, cross-domain, early-release, and one-past bounds are covered.

    The full clean release replay at 3cf60a1 passed, including CKB-VM and pinned CKB 0.207.0 production admission. Independent review was explicitly waived by the maintainer for this bounded 0.30 scope; it was not performed or claimed as passed. Broader transaction-view work remains separately scoped. Closing as complete.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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