Hi, community,
Thank you again for the feedback after last week’s early CellScript preview. The comments were genuinely insightful and inspiring.
This post is a current 0.13 direction update focused on the action model, syntax, and state transitions supported by the implementation today.
The Core Model
CellScript 0.13 makes one idea explicit:
A transaction proposes a Cell transformation.
An action verifies whether that transformation is allowed.
An action is not a method call on a contract object. It is not an account update. It is a typed verifier case.
It names:
- the input evidence a transaction wants to spend or read;
- the output evidence it wants to create;
- the proof obligations that make the transformation valid.
The intended shape is:
action fill_offer(before: Offer) -> after: Offer
where
require after.price == before.price
Read it as:
Spend one Offer input named before.
Validate one Offer output named after.
The proof lives under where.
Signature Direction
0.13 moves transaction direction into the action signature:
action name(inputs...) -> outputs
where
proof obligations
For example:
action mint(auth: MintAuthority, to: Address, amount: u64)
-> (next_auth: MintAuthority, token: Token)
where
require auth.minted + amount <= auth.max_supply
require next_auth.token_symbol == auth.token_symbol
require next_auth.max_supply == auth.max_supply
require next_auth.minted == auth.minted + amount
create token = Token {
amount,
symbol: auth.token_symbol
} with_lock(to)
The signature describes topology.
The require lines prove continuity.
That separation is deliberate. Auditors should be able to see exactly which fields are preserved and which fields change.
Named Outputs
0.13 prefers named outputs:
action split(token: Token, to_a: Address, to_b: Address)
-> (left: Token, right: Token)
where
require token.amount > 1
consume token
create left = Token {
amount: token.amount / 2,
symbol: token.symbol
} with_lock(to_a)
create right = Token {
amount: token.amount - token.amount / 2,
symbol: token.symbol
} with_lock(to_b)
Named outputs avoid positional guessing. The compiler and the reader both know which proposed output is being constrained.
create name = T { ... }
The canonical output constraint form is:
create token = Token {
amount,
symbol
} with_lock(owner)
This does not allocate a Cell at runtime. The transaction has already proposed outputs. The verifier checks that the named output has the expected type, data, and lock.
Field shorthand is allowed:
create token = Token {
amount,
symbol
} with_lock(owner)
This means:
create token = Token {
amount: amount,
symbol: symbol
} with_lock(owner)
No hidden renaming. No invented fields.
where Proof Blocks
Actions use a structured where block:
action transfer_token(token: Token, to: Address) -> next_token: Token
where
consume token
create next_token = Token {
amount: token.amount,
symbol: token.symbol
} with_lock(to)
The header says what transaction shape is being checked.
The where block proves why it is valid.
Inside where, use:
require
let
if / else
match
consume / destroy / claim / settle / transfer
create
helper calls
Ordinary helper functions still use braces:
fn min(a: u64, b: u64) -> u64 {
if a < b { a } else { b }
}
fn is value-level code. It does not bind transaction inputs or outputs.
State Is Data
0.13 does not hide lifecycle state in compiler magic. If a protocol has state, put it in the Cell data:
enum OfferState {
Created,
Live,
Filled,
Cancelled,
}
resource Offer has store {
state: OfferState
seller: Address
buyer: Address
price: u64
}
Enum order does not define a lifecycle. This:
enum OfferState {
Created,
Live,
Filled,
Cancelled,
}
does not imply:
Created -> Live -> Filled -> Cancelled
The transition graph is declared separately.
Flows Declare Legal Edges
Use flow to declare allowed state transitions:
flow Offer.state {
Created -> Live;
Live -> Filled;
Live -> Cancelled;
}
Or bind edges to actions:
flow OfferFlow for Offer.state {
Created -> Live;
Live -> Filled by fill_offer;
Live -> Cancelled by cancel_offer;
}
Rules:
One state field should have one flow.
Keep legal edges in one place.
An action move must match a declared edge.
A by action_name edge must be proved by that action.
This avoids scattering state transitions across unrelated actions.
move Declares The Edge
Use singular move, with colons:
action fill_offer(input: Offer) -> output: Offer
move input.state: Live -> output.state: Filled
where
require output.price == input.price
require output.seller == input.seller
Read it as:
input.state must be Live.
output.state must be Filled.
The edge Live -> Filled must exist in the flow.
move belongs before where, not inside it:
action settle(input: Position) -> output: Position
move input.phase: Open -> output.phase: Settled
where
require output.owner == input.owner
move declares the claimed state edge.
where proves the constraints.
Source Qualifiers
Not every parameter is a consumed input Cell. 0.13 uses prefix source qualifiers:
action grant_vesting(
read config: VestingConfig,
tokens: Token,
beneficiary: Address
) -> grant: VestingGrant
where
require tokens.symbol == config.token_symbol
require tokens.amount > 0
consume tokens
create grant = VestingGrant {
beneficiary,
total_amount: tokens.amount,
claimed_amount: 0
} with_lock(beneficiary)
Common qualifiers:
| Syntax | Meaning |
|---|---|
read config: T |
Read-only CellDep/reference-backed data |
protected cell: T |
Lock-guarded Cell view |
witness sig: T |
Decoded witness data |
lock_args args: T |
Typed lock script args |
Prefer:
read config: VestingConfig
not lower-level source encoding in the action signature.
Lifecycle Verbs
Cell-backed inputs must have a clear fate:
| Verb | Meaning |
|---|---|
consume x |
Spent as protocol material |
destroy x |
Terminates, if the type has destroy |
transfer x to owner |
Same data, different lock or owner |
claim x |
Redeems a receipt |
settle x |
Finalises an obligation/order/receipt |
Example:
resource Token has store, destroy {
amount: u64
symbol: [u8; 8]
}
action burn(token: Token)
where
require token.amount > 0
destroy token
No output does not silently mean destroy. The action should say what happens.
Small Complete Example
enum OfferState {
Created,
Live,
Filled,
Cancelled,
}
resource Offer has store {
state: OfferState
seller: Address
buyer: Address
price: u64
payment_symbol: [u8; 8]
}
resource Token has store, destroy {
amount: u64
symbol: [u8; 8]
}
flow Offer.state {
Created -> Live;
Live -> Filled;
Live -> Cancelled;
}
Publish:
action publish(input: Offer) -> output: Offer
move input.state: Created -> output.state: Live
where
require output.seller == input.seller
require output.buyer == input.buyer
require output.price == input.price
require output.payment_symbol == input.payment_symbol
Fill:
action fill(input: Offer, payment: Token, buyer: Address)
-> (output: Offer, seller_payment: Token)
move input.state: Live -> output.state: Filled
where
require payment.amount == input.price
require payment.symbol == input.payment_symbol
require output.seller == input.seller
require output.buyer == buyer
require output.price == input.price
require output.payment_symbol == input.payment_symbol
consume payment
create seller_payment = Token {
amount: payment.amount,
symbol: payment.symbol
} with_lock(input.seller)
Cancel:
action cancel(input: Offer) -> output: Offer
move input.state: Live -> output.state: Cancelled
where
require output.seller == input.seller
require output.buyer == input.buyer
require output.price == input.price
require output.payment_symbol == input.payment_symbol
Style Checklist
For 0.13-style actions:
Put Cell-backed inputs on the left side.
Put proposed output Cells on the right side.
Use named outputs.
Use source qualifiers for read/protected/witness/lock_args data.
Put move before where.
Use move field: From -> field: To.
Use where for proof logic.
Use require for constraints.
Use create for output constraints.
Use lifecycle verbs for consumed inputs.
Declare flow separately.
Keep state visible as data.
Do not use replace/replaces.
The Short Version
Signature is transaction topology.
State is explicit data.
Flow declares legal edges.
Move binds an action to one edge.
Where scopes the proof.
Require states verifier constraints.
Create constrains proposed outputs.
Or even shorter:
The action header says what changes.
The where block proves why it is allowed.
Feedback is very welcome, especially on whether this action surface feels clear enough for real CKB protocol examples and audit review.