CWScript: designing a smart contract language that compiles to Rust
How I decided what the language should understand, what Rust should stay responsible for, and how to translate between the two.
CWScript is a language I designed for CosmWasm smart contracts. The source reads in terms of contracts, state, messages, permissions, transfers, and events, then compiles into a normal Rust crate.
The hard part was not inventing cleaner syntax. It was deciding what the language should understand, what Rust should still be responsible for, and how to translate between the two without turning CWScript into thin shorthand for Rust.
This is how I approached those decisions, from the grammar through to the generated crate.
Start with the right abstraction
A CosmWasm contract is a Rust crate compiled to WebAssembly. Rust is a strong implementation language for that job, but its primitives are not the primitives of a contract.
A token transfer is conceptually simple: validate a recipient, debit one balance, credit another, and emit an event. In Rust, the same operation also involves storage handles, serialization, closures around map updates, error types, and the context objects passed in by CosmWasm.
Those details are necessary at the platform boundary. They are not the contract's business logic.
That distinction motivated CWScript. I wanted the source language to operate at the level at which developers reason about contracts, while still producing ordinary CosmWasm code underneath.
The design method was subtraction. Start with the expressive power of Rust, then keep only the operations that make sense inside a smart contract. A new construct had to do one of two things: name a real CosmWasm concept, or make a useful restriction enforceable.
The second part matters. A restricted language is only worthwhile if the restriction buys something back: clearer behavior, safer state access, stronger validation, or code that is easier to audit.
CWScript also had to respect CosmWasm rather than hide it behind a new runtime. The generated output would use the same messages, storage libraries, module structure, and entry points as a hand-written contract. Developers could inspect the Rust, test it with the existing toolchain, and use the rest of the ecosystem normally.
That made CWScript less like a replacement for Rust and more like a contract-level frontend for it.
Why build a language instead of a macro?
The cheaper route was a Rust procedural macro. Most of CWScript's surface could have been embedded in Rust, and prior work such as ink! showed how far that approach could go.
Macros have a major advantage: interoperability is automatic. The macro can use Rust's parser, type system, libraries, compiler, and editor tooling. There is far less infrastructure to build.
But a macro can add syntax without truly taking syntax away. Even if it validates everything inside one annotated module, ordinary Rust remains available around the boundary. That makes it difficult to state that a certain operation is impossible rather than merely discouraged.
CWScript was built around subtraction. If queries should never mutate state, or if state may only be changed through approved transitions, those rules need to apply to the whole source program. A separate language gives the compiler control over the complete set of valid operations.
I considered several other starting points:
| Approach | What it offered | Main tradeoff |
|---|---|---|
| Rust procedural macros | Rust interoperability and much less compiler work | The Rust abstraction remains reachable and can leak through |
| Lisp or Racket | Source code that already resembles an AST | The syntax is unfamiliar to much of the intended audience |
| LLVM IR | A mature, proven intermediate representation | It operates far below the level of contracts and state |
| Langium | A generated parser, semantic model, language server, and editor tooling | The compiler has to follow Langium's document and service architecture |
| Z3 or symbolic execution | A path toward formal verification | Verification still depends on first defining precise language semantics |
The question was not which technology was most powerful. It was which layer should own the restrictions. Once I decided that CWScript itself had to own them, a compiler became the natural shape of the project.
Define the prototype with existing contracts
A new language should not begin by proving that it can express a clever new feature. It should first prove that it can express ordinary programs in its domain.
My initial target was to translate four existing CosmWasm contracts: CW20, CW721, Terraswap, and Mirror. Alongside them, the prototype needed a formal grammar, documentation, syntax highlighting, and the beginning of a language server.
That benchmark served two purposes.
First, it kept the language tied to real contracts. A syntax decision that looked elegant in a counter example might become awkward in a token contract with allowances, submessages, replies, and multiple storage maps.
Second, it exposed the recurring translations that should shape the compiler. If several contracts lower the same source operation into the same Rust pattern, that pattern probably belongs in the intermediate representation or runtime model.
The compiler architecture evolved into this pipeline:
source -> AST -> validation -> validated AST -> codegen IR -> Rust crate
|
-> diagnostics
Each stage has a distinct job:
- The parser decides whether the source is valid CWScript and produces the AST.
- Validation resolves names, checks types, and enforces contract-specific restrictions.
- The code-generation IR converts general syntax into explicit CosmWasm operations.
- The backend renders those operations as a Rust crate.
Keeping those responsibilities separate became one of the most important architectural decisions in the project.
Designing a surface that is familiar but unmistakable
CWScript had to satisfy two competing goals.
It needed to look familiar enough that a TypeScript or Rust developer could learn it quickly. But it also needed to look different enough that nobody would assume Rust or TypeScript semantics where CWScript behaved differently.
AssemblyScript illustrates the risk of looking too familiar: the code resembles TypeScript closely enough that developers bring TypeScript assumptions with them. The differences become traps instead of visible design choices.
I kept CWScript closer to TypeScript than Rust, but concentrated its distinctiveness in a few small markers:
$marks ambient contract context, such as$state,$info, and$env.#marks messages and entry-point names.!marks fallible or effectful forms.@introduces annotations.
The syntax is recognizable at a glance without requiring a separate grammar for every contract concept. A developer learns four markers, then reads mostly familiar expressions, blocks, functions, and types.
These markers are lexical, not decorative. The lexer produces different tokens for a local name, a $ context name, and a # message name. The parser and later compiler passes therefore know the distinction before name resolution begins.
That makes the syntax carry semantic information at very little cost.
Entry points are typed functions
CosmWasm contracts expose instantiate, execute, and query entry points. Within those entry points, message variants are usually dispatched to handler functions.
CWScript could have modeled every handler as a completely separate declaration. Instead, I treated handlers as functions with a contract-specific type.
The grammar reflects that relationship:
fnDefn: (doc)? (exported = EXPORT)? FN (name) (fallible = BANG)? (typeParams)? (params) (ARROW returnTy)? (body)
execDefn: (doc)? EXEC (name) (fallible = BANG)? (params) (ARROW returnTy)? (body)
queryDefn: (doc)? QUERY (name) (fallible = BANG)? (params) (ARROW returnTy)? (body)
exec and query are not merely labels. They determine the function's available context and legal effects, much as async changes the type and behavior of a JavaScript function.
An execute handler can receive mutable storage and sender information. A query receives read-only storage and no sender. A fallible handler uses the ! marker, allowing the compiler to generate the appropriate Rust result type and error propagation.
Here is a transfer handler:
exec #transfer(recipient: String, amount: U128) {
if amount == 0 {
fail! InvalidZeroAmount();
}
let rcpt_addr = Addr.validate!(recipient);
$state.balances[$info.sender] -= amount;
$state.balances[rcpt_addr] += amount;
emit Transfer($info.sender, rcpt_addr, amount);
}
The source contains the complete business operation. It validates the amount and recipient, changes two balances, and emits an event. Storage loading, serialization, map-update closures, response construction, and Rust error plumbing belong to the compiler.
This is the central trade CWScript makes: the source becomes more specific to contracts, while the generated code becomes more explicit about the platform.
Treat cross-contract calls as first-class operations
Calls between contracts are another place where the conceptual operation is much smaller than its Rust representation.
A CosmWasm submessage may need a message payload, target contract, funds, gas limit, reply policy, numeric reply ID, and a handler that decodes the response. In Rust, those concerns are spread across several constructors and a separate reply entry point.
CWScript puts the call in one statement:
@gas_limit(5000000)
@reply.on_success(post_instantiate)
instantiate! #TerraswapPair(
asset_infos,
$state.config.token_code_id,
asset_decimals
) {
code_id: $state.config.pair_code_id,
admin: $env.contract.address,
label: "pair"
}
The statement says what is being instantiated and with which values. The annotations carry operational policy: cap the gas and call post_instantiate after a successful reply.
This separation keeps the main operation readable without pretending the metadata does not exist. It also gives the compiler structured information from which it can generate message constructors, reply IDs, dispatch code, and handler registration.
The same principle applies to emit, exec, and fail: operations that are library calls in Rust become first-class forms when the compiler needs to reason about them.
Separate omitted arguments from nullable values
Rust's Option<T> often represents two different facts:
- The caller may omit an argument.
- The argument is present, but its value may be absent.
When both are true, Rust uses Option<Option<T>>. The type is correct, but the source no longer makes the distinction easy to read.
CWScript puts the two facts in different positions:
hello?: str // the argument may be omitted
hello: str? // the value may be null
bye?: str? // the argument may be omitted and its value may be null
The parameter name describes call-site behavior. The type describes the value.
This distinction has consequences for the type system. A nullable value cannot be used as its underlying type until the program discharges the null case. The language therefore needs explicit rules for narrowing, defaulting, and propagation.
For example:
#instantiate(count: U32?, owner: Addr?) {
if count? {
// count is U32 inside this block
}
$state.count = count ?? 0;
$state.owner = owner ?? $info.sender;
}
The design question is not only which operator looks best. The compiler has to define how each construct changes the type of a binding, whether the right side of ?? is evaluated lazily, and how optional values cross the Rust boundary.
This is a good example of why syntax cannot be designed in isolation. A two-character operator implies rules in the parser, type checker, intermediate representation, and Rust generator.
State syntax determines the semantic model
State is the center of a smart contract language. The surface notation has to balance three goals:
- Direct reads and writes should be concise.
- The compiler must know which expressions touch persistent storage.
- More complex updates must remain atomic and auditable.
The simplest syntax treats a map like an ordinary collection:
$state.balances[owner] -= amount;
That is easy to read, but its Rust translation is not an ordinary index assignment. It may require loading a value from storage, applying checked arithmetic, handling a missing key, and saving the result.
For updates with more logic, a closure can make the storage transaction explicit:
$state.allowances[[$info.sender, spender_addr]].update(
|allow| {
// validate and return the new allowance
}
);
These forms do not have to be competing spellings. They can be two levels of one model:
- Compound assignment is syntax sugar for a standard read-modify-write operation.
updateexposes the operation when validation, deletion, or custom error handling is needed.
The compiler can normalize both into the same internal state transition before generating Rust.
This normalization point is important. Surface syntax should optimize for the author. The IR should optimize for precise meaning. The Rust backend should optimize for correct and regular output.
It also creates a place to enforce stronger state rules. A contract could declare permitted transitions beside a state field, then validation could reject handler code that attempts any other mutation. For example, a counter might allow changes only by one and only when the sender is the owner.
The useful guarantee is not that developers usually update state safely. It is that every state write in the program passes through a small set of operations the compiler understands.
The grammar is the inventory of the language
Because CWScript is defined partly by what it removes, its grammar is more than a parser specification. It is the complete inventory of operations a contract may express.
The statement rule contains seventeen alternatives:
stmt:
importStmt
| exportStmt
| defn
| letStmt
| constStmt
| assignStmt
| memberAssignStmt
| indexAssignStmt
| ifStmt
| tryCatchElseStmt
| forStmt
| execStmt
| instantiateStmt
| emitStmt
| failStmt
| returnStmt
| exprStmt;
Several entries would be ordinary calls in Rust. Giving them dedicated productions lets the parser restrict where they appear and gives the AST an exact node type for each one.
For example, emit can be restricted to an execute context. instantiate! can require a reply policy when its return value is used. fail! can mark a control-flow edge as terminating. A general function-call node would force later passes to rediscover all of that from names and conventions.
There is a cost. A richer AST means more node types, visitors, formatting rules, diagnostics, and code-generation cases. The grammar should therefore make a construct first-class only when the compiler needs first-class knowledge of it.
That rule prevents a domain-specific language from becoming a collection of arbitrary syntax preferences.
Choosing the parser and AST architecture
I explored Lark, ANTLR, Chevrotain, and Langium while building the front end. The choice was not mainly about parsing speed. Each tool implied a different relationship between the grammar, AST, semantic model, and editor.
ANTLR provides a mature parser ecosystem and a clear separation between the grammar and a hand-built AST. That gives the compiler full control over the tree, but every grammar change has to be reflected in AST construction and visitors.
Chevrotain puts the grammar in TypeScript and offers tight control over parsing. Translating the grammar into a second framework also acts as a useful test: ambiguities that one parser resolves implicitly often become visible when another requires the choice to be explicit.
Langium takes a more integrated approach. The grammar defines a semantic model, cross-references, and enough structure to generate a language server and VS Code extension. The AST is not merely a parse result; it participates in a document lifecycle with linking, validation, and diagnostics.
The architectural lesson was to avoid maintaining two versions of the same tree. If the framework's semantic model already represents the program the compiler needs, deriving a second "official" AST creates synchronization work without adding information.
A single canonical model lets the parser, language server, validator, and compiler agree on node identity and source locations. It also improves diagnostics because every later pass can report errors against the same document objects the editor already knows.
Validation is where the language becomes more than syntax
A parser can tell that this is a correctly shaped assignment:
$state.count += 1;
It cannot tell whether count exists, what type it has, whether the current handler may modify it, or which Rust storage primitive represents it.
Those questions require semantic analysis.
The first building block is a symbol table: a map from each name in the source to the declaration it refers to. Contracts introduce namespaces for state fields, messages, errors, events, functions, imports, and inherited members. Function bodies add parameters and locals. $state.count should resolve to a state declaration, while a bare count may resolve to a local binding.
Once names resolve, validation can enforce contract-specific rules:
- A query cannot access sender information or mutable storage.
- An execute handler may emit events and submessages; a pure function may not.
- A state update must match the field's declared type and transition rules.
- A message name must refer to a declared or imported message type.
- A fallible call must be propagated, handled, or used inside a fallible handler.
- An omitted argument and a nullable value must be checked independently.
This is also where the strongest version of CWScript becomes possible. If state may only be changed through operations represented in the validated AST, then permissions and invariants can be attached to those operations.
For example, imagine a state declaration that says count may only change by one and only when $info.sender == owner. The validator can check every assignment that resolves to that state field. The rule becomes a property of the language, not a comment authors are expected to follow.
The type system does not need to begin with formal proof. It needs to begin with enough information to resolve names, distinguish persistent state from locals, classify effects, and lower each expression unambiguously. Stronger analysis can build on that foundation.
Work backward from valid Rust
Designing the intermediate representation in the abstract produced too many plausible options: a stack machine, a Lisp-like IR, monadic operations, or a runtime with emitted instructions.
The more useful method was to start with the Rust.
For each small CWScript program, write the canonical Rust output by hand and make sure it compiles. Then compare several translations and extract the operations that recur. Those operations become the code-generation IR.
The loop looks like this:
- Choose the smallest contract that introduces one new behavior.
- Write its desired Rust output.
- Compile and test that Rust.
- Identify the mapping from source constructs to Rust constructs.
- Add the smallest IR operation that captures the mapping.
- Generate the Rust and lock it in as a fixture.
This approach answers design questions with evidence. If three state assignments all lower to load, transform, and save, the IR probably needs a state-update operation. If execute and query handlers share most of their output, they should probably share one handler representation with different capabilities.
It also prevents the IR from becoming a second general-purpose language. CWScript's IR does not need to represent every possible computation. It needs to represent the small set of decisions required to generate correct CosmWasm Rust.
The IR should move toward the domain, not the machine
Traditional compiler IRs lower source code toward machine primitives. LLVM IR is intentionally close to a platform-independent assembly language.
CWScript targets Rust, so lowering all the way to machine-like operations would throw away the information the backend needs most. The useful direction is the opposite: convert general syntax into explicit contract operations.
Consider:
$state.balances[$info.sender] -= amount;
The AST sees an index expression and a compound assignment. The code-generation IR can see something more specific:
StateMapUpdate {
map: balances,
key: InfoSender,
operation: CheckedSub(amount)
}
That representation tells the backend that it needs a storage map, a sender-derived key, checked arithmetic, error propagation, and a save. It also gives validators and other tools a meaningful operation to inspect.
The same idea applies to cross-contract calls, replies, emitted events, and query responses. The IR should preserve domain meaning until the last responsible moment.
Choose canonical Rust for generation, not imitation
One CWScript construct can often be rendered as several equally valid Rust programs. The backend needs a consistent answer.
There are two possible goals:
- Generate Rust that resembles what a human would write by hand.
- Generate a regular form that is easy to emit, verify, and test.
I chose the second.
Generated code is an implementation artifact. The source language should carry the readability. Regular Rust makes the backend smaller and produces stable fixtures that are easy to compare.
This affects module layout as well. Instead of reproducing whatever hierarchy a human might choose, the generator can flatten contract types into a predictable namespace and track their fully qualified Rust paths internally. Message types, error types, state handles, and handler functions all have deterministic names.
The backend then becomes a set of explicit mappings rather than a formatter trying to imitate human taste.
Generating the CosmWasm crate
The crate generator divides naturally into two parts:
- Generate the contract interface from declarations.
- Generate handler bodies from validated operations.
Declarations provide enough information to build much of the crate mechanically:
- A
stateblock becomescw_storage_plusitems and maps. - Error declarations become variants of a
thiserrorenum, along with the hostStdErrorconversion. exechandlers become variants ofExecuteMsg.queryhandlers become variants ofQueryMsgand typed response wrappers.- Handler declarations become implementation functions and dispatch arms.
- Contract annotations become entry-point and schema metadata.
The source keyword also determines the context type passed to the generated handler:
pub struct ExecuteCtx<'a> {
pub deps: DepsMut<'a>,
pub env: Env,
pub info: MessageInfo,
}
pub struct QueryCtx<'a> {
pub deps: Deps<'a>,
pub env: Env,
}This is a clean example of using Rust's type system as the final enforcement layer. The CWScript validator can reject a write in a query and produce a source-level diagnostic. If an invalid write somehow reaches the backend, Rust still refuses it because QueryCtx contains Deps, not DepsMut.
The source language and target language enforce the same rule at different layers.
Lowering state updates to Rust
The basic state translation is load, modify, save.
CWScript:
exec #increment() {
$state.count += 1;
}
Canonical Rust:
pub fn exec_increment_impl(ctx: ExecuteCtx) -> Result<Response, ContractError> {
let mut count: u32 = COUNT.load(ctx.deps.storage)?;
count += 1;
COUNT.save(ctx.deps.storage, &count)?;
Ok(Response::new())
}The source expresses a state transition. The generated code makes persistence and fallibility explicit.
This translation raises several decisions that belong in the validated model or IR:
- Is the storage value guaranteed to exist, or should the backend use
may_load? - Is arithmetic checked, saturating, or wrapping?
- Does a missing map entry imply a default value?
- Can multiple reads and writes be combined into one
Map::updateclosure? - When should a value be cached locally across several operations?
- Which errors are converted into
ContractError?
These are not formatting choices. They define the semantics of the source language.
The safest way to settle them is to choose one canonical rule for each source operation, encode it in the IR, and test the generated Rust against representative contracts.
Types must cross the boundary explicitly
Type generation looks mechanical until modules and foreign libraries enter the picture.
A source type may refer to:
- A built-in scalar such as
U128 - A contract-local struct or enum
- A type imported from another CWScript module
- A Rust type exposed through the standard library or FFI
- A message type generated from a handler declaration
- A storage wrapper that exists only in generated Rust
The compiler needs a resolved type model before code generation begins. Each source type should carry its identity and Rust path, not just its source text.
That allows the backend to answer practical questions consistently:
- Should
U128becomecosmwasm_std::Uint128or a generated alias? - Which types need
#[cw_serde]? - Where do generic arguments need explicit Rust paths?
- Which imported names must be re-exported?
- How are optional and omitted values represented in message structs?
This is one reason to keep type resolution out of string templates. By the time the backend renders Rust, the choice should already have been made.
Modules and the standard library force the design to become real
A single generated crate can avoid many difficult language questions. Modules cannot.
Imports, exports, inheritance, interfaces, and foreign functions require stable identities across files. The grammar can recognize an import or extends clause, but the semantic model has to resolve it to a specific declaration and the backend has to decide where that declaration lives in Rust.
CWScript's contract syntax anticipated composition:
export contract TerraswapToken extends Cw20Base {
// contract-specific state and handlers
}
Supporting this cleanly requires answers to several questions:
- Does inheritance copy handlers, delegate to them, or compose generated modules?
- Can a derived contract replace state expected by a base contract?
- How are message enums extended without breaking schema compatibility?
- What is the fully qualified identity of an imported contract or type?
- Which pieces become Rust modules, traits, or ordinary functions?
The standard library is an especially useful forcing case. If the library is written in Rust but called from CWScript, every function crossing the boundary needs a source name, CWScript type, Rust path, effect classification, and error behavior.
Building a small standard library early is therefore more valuable than designing a general FFI in the abstract. A few real functions force the calling convention, type mapping, module resolution, and documentation format to become concrete.
The larger opportunity: a representation of CosmWasm
The language was one part of a broader idea. CosmWasm contracts already share a high-level structure, but most tools interact with either Rust source or compiled WebAssembly.
If that structure is represented explicitly, CWScript does not have to be the only frontend. The same validated model could support:
- Alternative contract languages
- Generated client SDKs
- Documentation and schema tools
- Static analysis and security checks
- Visual contract explorers
- Migration and compatibility tooling
- Testing utilities that operate on contract semantics rather than source text
The compiler pipeline then becomes more than source-to-source translation. It becomes a stable representation of what a CosmWasm contract is.
This is why the AST and IR boundaries matter so much. A Rust-specific AST limits every downstream tool to Rust. A machine-level IR discards the contract concepts those tools need. The useful representation sits between them: resolved, typed, and contract-aware, but independent of how one backend renders it.
What the design process taught me
The hardest decisions in a domain-specific language are not about punctuation. They are about where meaning lives.
Does exec merely generate a function name, or does it define an effectful function type? Is $state.balances[key] -= amount ordinary assignment syntax, or a persistent state transition with checked arithmetic? Is a submessage a library call, or a first-class operation the compiler can validate and inspect?
CWScript became clearer whenever I answered those questions in terms of semantics first and rendering second.
The practical design principles are straightforward:
- Start with real contracts, not isolated syntax examples.
- Define the restriction each first-class construct makes enforceable.
- Keep one canonical semantic model across the parser, editor, and compiler.
- Resolve names, types, and effects before generating strings.
- Derive the IR by working backward from valid, tested Rust.
- Preserve contract meaning in the IR instead of lowering too early.
- Generate regular Rust rather than trying to imitate human style.
- Build modules and a small standard library early, because boundaries expose vague design.
The central idea behind CWScript is simple: a smart contract language should let the author write the contract while the compiler writes the platform integration.
Making that work requires more than concise syntax. The grammar, type system, validation rules, intermediate representation, and Rust backend all have to agree on what each contract operation means. Once they do, the compiler can remove a large amount of incidental complexity without hiding the behavior that matters.