Skip to main content

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.caller when the direct caller must be authorized.
  • Check ctx.origin only 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. origin only says who started the call chain.
  • For export = eth, remember that eth_guard = creator checks 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::ZERO unless 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:

  1. In instance logic, parse the raw observation into the exact value or target call input you want to propose.
  2. Use the certified-call flow to collect committee approval and submit the update.
  3. 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.