Programming Guidelines
This page collects Lyquid programming guidelines that keep applications predictable and easy to reason about. Earlier sections define the state model, function categories, host APIs, and certified-call flow; this page assumes those concepts and focuses on practical choices while the LDK is still evolving.
Designing States
The State Model page explains what network and instance mean.
The common mistakes are more specific:
Do not put secrets in network state.
API keys, private tokens, local signing material, provider credentials, and
private prompts belong in instance state or outside Lyquid state entirely. Also
avoid exposing them through logs, return values, HTTP responses, UPC responses,
or certified-call evidence.
Keep the authoritative copy of shared policy in network state.
Values such as admin rights, role membership, enabled Lyquid IDs, oracle
committee configuration, spending limits, and protocol parameters can be cached
or indexed in instance state, but network state should remain the source of
truth.
Do not use network state as an event log or raw response store by default.
It is replicated state, so unbounded histories, raw web/API responses, model
outputs, large blobs, and debug traces make every node carry data the Lyquid
usually does not need for future transitions.
Minimize the network state footprint.
If a value can be deterministically derived from network state, compute it in a
read method or maintain it in a node-local instance cache. When a network
transition should refresh local derived data, schedule the instance work with
TriggerMode::Commit. This reduces replicated state and improves performance
without changing the shared result.
Treat instance caches and derived indexes as local.
A node can have an empty, stale, or rebuilt cache. Instance state is a good
place for retry cursors, raw observations, provider responses, and expensive
local indexes, but network logic should not depend on every node having the same
instance contents.
Make instance-state updates idempotent where practical.
Instance functions may be retried, triggered again after a commit, or receive
duplicate external events. Prefer writes that converge on the same local result,
such as upserts, last-seen cursors, request IDs, and deduplicated work queues.
WASM Determinism
The LDK restricts network methods to deterministic host APIs. In WASM code itself, the main sharp edges are floating-point values, relaxed SIMD, and hash-based collections.
Use integers for balances, quotas, counters, voting weights, timestamps,
prices, and anything else that affects shared state. For decimal values, pick a
scale and store the scaled integer value, such as cents, basis points, or
price * 1_000_000. Check overflow and underflow before writing state.
Avoid f32 and f64 in network state and certified-call inputs when the value
is meant to be exact. The WebAssembly numeric
semantics allow
some operators to produce one of several valid NaN values. The LVM enables
Wasmtime IEEE-754 NaN
canonicalization,
but floats remain approximate and still have non-finite values, negative zero,
and decimal rounding edge cases. If an instance method receives a float from an
API, model, or user request, reject non-finite values and convert the accepted
value to a scaled integer before it reaches shared state.
Standard WASM SIMD is fine for compiler and runtime optimizations. Do not depend on relaxed SIMD for network logic; the LVM rejects relaxed SIMD modules because those instructions can be architecture-dependent.
Use the HashMap and HashSet types re-exported by the LDK prelude, and
initialize state variables with new_hashmap() and new_hashset(). For
network state, prefer the LDK re-exports so seeding and iteration behavior are
explicit.
Caller and Authority
Give every mutable entry point a clear authority rule. Generated export guards and transport-level authentication are useful, but they should not be the only policy when the Lyquid function can be reached through multiple paths.
Use ctx.caller for the direct caller and ctx.origin for the original
external origin. They are not interchangeable:
- Check
ctx.callerwhen the direct caller must be authorized. - Check
ctx.originonly when the policy is explicitly about the original external actor. - For permissions, check the role, allowlist, configured Lyquid ID, committee,
or other capability that grants the action.
originonly says who started the call chain. - For
export = eth, remember thateth_guard = creatorchecks the generated Ethereum wrapper path only. Validate authority inside the Lyquid function when non-Ethereum, internal, or certified paths must also be restricted.
A typical admin update records authority in network state and checks it on each mutation:
state! {
network admin: Address = Address::ZERO;
network paused: bool = false;
}
#[method::network(export = eth)]
fn constructor(ctx: &mut _) {
*ctx.network.admin = ctx.caller;
}
#[method::network(export = eth)]
fn set_paused(ctx: &mut _, paused: bool) -> LyquidResult<bool> {
if ctx.caller != *ctx.network.admin {
return Err(LyquidError::LyquidRuntime("unauthorized".into()));
}
*ctx.network.paused = paused;
Ok(true)
}
Input Validation
Validate inputs at the boundary that receives them. Return Err(...) for
expected rejection cases; reserve panic! for bugs.
When applicable, check:
- bounded values, such as amounts, indexes, lengths, deadlines, and thresholds;
- arithmetic safety before state mutation, including overflow and underflow;
- authority for shared actions, including caller identity, roles, and allowlists;
- service identities, such as
LyquidID,NodeID, callback Lyquid IDs, and configured targets; - certified-call envelopes, including target, method, group, ABI, caller, origin, and encoded input;
- UPC peer identity, when requester or responder identity affects local policy;
- HTTP exports, including method, path, headers, body size, and content type;
- blockchain-facing addresses, including rejecting
Address::ZEROunless zero has a documented meaning.
Keep invariants close to the mutation. For balance-like state, reject invalid addresses and zero amounts, check source balance, check destination overflow, and only then write either side.
External Data and Certified Calls
External data starts as a local observation. Web/API results, model outputs, local signatures, UPC responses, and oracle observations can differ by node. They do not become shared facts just because one instance saw them.
A compact pattern for turning local observations into shared state:
- In instance logic, parse the raw observation into the exact value or target call input you want to propose.
- Use the certified-call flow to collect committee approval and submit the update.
- In the target network method, enforce the remaining application invariants before changing network state.
Keep validation evidence explicit. extra bytes are checked by committee nodes
but are not submitted to the target. If all approvers must inspect the same
evidence, bind a hash of that evidence into the certified call input.
Interactions Between Lyquids
call! is for network-method calls between Lyquids on the same sequence
backend. The nested call is atomic with the current network transition: if it
fails, the whole execution unwinds. For Lyquids on different sequence backends,
use a certified call instead.
Treat the callee Lyquid ID and return value as inputs: check that the call
targets the intended Lyquid, and validate returned data before it drives later
logic.
UPC is instance-side. It is useful for asking nodes for local responses, but a UPC result is not automatically shared state. If a UPC result should affect network state, route it through a network call or certified-call flow.
Commit-time triggers are useful when a network transition should start local work after it commits. They are asynchronous post-commit work, not a sequencing barrier, but per-Lyquid commit-trigger execution will preserve commit order. Validate the network input before scheduling the trigger. The triggered instance work can create local observations, but those observations are not shared state until submitted through a network or certified-call path.
Exported Surfaces
Every exported function is a public boundary. Treat exported instance functions as node-local service endpoints and exported network functions as shared state transition endpoints.
HTTP exports are node-local request boundaries. Authenticate non-public operations, keep request bodies and parser work bounded, reject unexpected methods and content types, and return stable application errors instead of node-local secrets or raw provider failures.
Ethereum exports are ABI-facing entry points. Use Solidity-compatible signatures deliberately, keep retryable mutable operations idempotent where practical, and enforce application authority inside the function when callers can also reach it through non-Ethereum paths. An EVM receipt confirms sequencer acceptance before hosting nodes execute the Lyquid body, so frontends should confirm effects with a read method or hosted status endpoint when the result matters.