Skip to main content

Functions

Lyquid's basic unit of computation is the invocation of a network function or an instance function. These are callable entry points exposed to the Lyquor VM, distinct from ordinary Rust helper functions you may define for internal application code.

Network and instance functions must be top-level module functions tagged with attribute macros under method, such as #[method::network] or #[method::instance]. The macro generates the runtime wrapper, ABI metadata, input/output decoding, and context type for that function.

They map naturally to the two state categories:

// Ordinary Rust helper function: not callable by Lyquor VM.
fn normalize_symbol(symbol: &str) -> String {
symbol.trim().to_ascii_uppercase().replace('-', "")
}

// Lyquid network function: updates shared state.
#[method::network]
fn set_price(ctx: &mut _, symbol: String, price: U256) -> LyquidResult<bool> {
let symbol = normalize_symbol(&symbol);
ctx.network.prices.insert(symbol, price);
Ok(true)
}

// Lyquid instance function: reads shared network state locally.
#[method::instance]
fn get_price(ctx: &_, symbol: String) -> LyquidResult<Option<U256>> {
let symbol = normalize_symbol(&symbol);
Ok(ctx.network.prices.get(&symbol).copied())
}

In this example, normalize_symbol is just Rust code. set_price and get_price are Lyquid Functions because they are tagged with #[method::*].

Network Functions

Network functions are the shared, sequenced entry points of a Lyquid. A caller submits a network call to a sequence backend, which orders accepted calls for that Lyquid. Nodes hosting the Lyquid then execute the same ordered call stream in the Lyquor VM, so each successful call produces one deterministic network-state transition. Network functions can read and write network state, but they cannot access instance state or depend on node-local nondeterminism. In cloud terms, this is closest to state-machine replication: the sequence backend supplies a consensus-backed ordered log, and hosting nodes apply that log deterministically. For cloud readers, compare the log to a Kafka topic partition and the state-transition loop to a Kubernetes controller reconciling desired state. In blockchain terms, it is a public or external smart-contract method.

#[method::network]
fn transfer(ctx: &mut _, to: Address, amount: U256) -> LyquidResult<bool> {
let from = ctx.caller.clone();
transfer_balance(&mut ctx.network, from, to, amount)?;
Ok(true)
}

#[method::network]
fn approve(ctx: &mut _, spender: Address, value: U256) -> LyquidResult<bool> {
set_allowance(&mut ctx.network, ctx.caller, spender, value, true)?;
Ok(true)
}

Atomic Composability

Network functions can invoke another Lyquid's network functions with the call! macro, giving you the same "on-chain composability" as contracts calling contracts on Ethereum. Here is a real example from the BasicSwap example, which moves tokens by calling an ERC20 Lyquid:

fn _safe_transfer(token: LyquidID, to: Address, amount: U256) -> LyquidResult<()> {
if !call!((token).transfer(to: Address = to, amount: U256 = amount) -> (success: LyquidResult<bool>)).success? {
return Err(LyquidError::LyquidRuntime("TRANSFER_FAILED".into()));
}
Ok(())
}

The syntax mirrors a function declaration: (callee) is any expression yielding a LyquidID, each parameter is written as name: Type = value, and each named output becomes a field on the returned value. call! has strict atomic semantics: if the inter-Lyquid call fails, the whole sequenced execution unwinds together, just like a reverted nested call on Ethereum. It is only usable from network functions; instance function code coordinates with other Lyquids and nodes via UPC instead. Also note that call! targets functions by name in the default main group only. Groups are covered in Groups and UPC.

Instance Functions

Instance functions execute on a particular hosting node. They may still be publicly exported or externally triggered, but each invocation runs against one node's local environment and instance state. They can read network state and read/write that node's instance state, but they cannot mutate network state directly. Because execution is per-node, instance functions may use external I/O or other inputs that differ from node to node.

For cloud and AI readers, an instance function is closest to an advanced AWS Lambda-style handler: it can run as an endpoint or background task, but it also has Lyquid-managed persistent instance state, read access to network state, and native trigger/UPC integration.

Use instance functions for service endpoints, API/model calls, caches, custom protocols, off-chain workers, application code embedded directly in user-side clients, and UPC workflows. They can also aggregate results across multiple nodes via UPC, which supports patterns such as custom oracles, bridging, decentralized order matching, and multiparty computation.

#[method::instance]
fn balance_of(ctx: &_, account: Address) -> LyquidResult<U256> {
// Like a Solidity `view` function: read network state and return a value.
Ok(get_balance(&ctx.network, &account).clone())
}

#[method::instance]
fn refresh_cache(ctx: &mut _, symbol: String) -> LyquidResult<bool> {
let price = fetch_price_from_api(&symbol)?;
ctx.instance.price_cache.write().insert(symbol, price);
Ok(true)
}

#[derive(serde::Serialize, serde::Deserialize)]
struct SupportTriage {
label: String,
priority: u8,
rationale: String,
}

#[method::instance]
fn triage_support_ticket(ctx: &_, ticket: String) -> LyquidResult<SupportTriage> {
// `gemini_api_key` is instance state, so each node can use its own provider key.
let api_key = {
let stored = ctx.instance.gemini_api_key.read();
stored.trim().to_owned()
};

let model = "gemini-2.5-flash";
let endpoint = "https://generativelanguage.googleapis.com/v1beta";
let url = format!("{endpoint}/models/{model}:generateContent");
let backend = lyquid_llm::backend::gemini::GeminiBackend::new("gemini", url).api_key(&api_key);
let prompt = format!(
"Classify this support ticket as billing, bug, account, or other. \
Return JSON with fields: label, priority, rationale.\n\n{ticket}"
);
let request = lyquid_llm::ModelRequest::new(model, prompt)
.system("Respond only with valid JSON matching the requested fields.")
.timeout_ms(10_000)
.max_output_tokens(256)
.temperature(0.3)
.body_field(
"generationConfig",
serde_json::json!({ "responseMimeType": "application/json" }),
);

let model_response = lyquid_llm::call_model(&backend, &request)?;
let text = model_response
.text
.ok_or_else(|| {
LyquidError::LyquorRuntime(format!(
"gemini returned no text finish_reason={:?}",
model_response.finish_reason
))
})?;

// Strip ``` fences in case the model adds them despite the JSON response hint.
let trimmed = text.trim();
let body = trimmed
.strip_prefix("```json")
.or_else(|| trimmed.strip_prefix("```"))
.and_then(|s| s.rsplit_once("```").map(|(b, _)| b))
.unwrap_or(trimmed)
.trim();

serde_json::from_str(body).map_err(|e| LyquidError::LyquorRuntime(format!("parse Gemini JSON: {e}")))
}

Function Signatures

Ordinary Lyquid network and instance functions must follow this shape:

#[method::network] // or ::instance
fn func_name(ctx: &mut _ /* or &_ */, arg1: Type1, arg2: Type2, ..., argN: TypeN) -> LyquidResult<T> {
// ... (function body)
Ok(value) // Or Err
}

Rules enforced by the attribute macro:

  • The function must be a plain Rust function: not async, const, extern, or variadic. It must not have generic parameters or a where clause.
  • The first parameter must be a named context reference, such as ctx: &mut _ or ctx: &_. Do not use top-level method receivers like self, &self, or &mut self.
  • Every later parameter must be named, such as amount: U256.
  • The function must return LyquidResult<T>, where T is a return type given by the developer, except for special cases such as the constructor. Prefer explicit Err(LyquidError::...) for expected failures. Avoid panic! for domain errors.
#[method::network]
fn transfer(ctx: &mut _, to: Address, amount: U256) -> LyquidResult<bool> {
if to == Address::ZERO {
return Err(LyquidError::LyquidRuntime("invalid receiver".into()));
}
Ok(true)
}

Important error variants:

VariantTypical meaning
LyquidError::LyquorInputHost or external input could not be decoded
LyquidError::LyquidInputGuest supplied invalid input to a host-facing operation
LyquidError::LyquorOutputHost output could not be decoded
LyquidError::LyquidOutputGuest returned invalid output
LyquidError::LyquorRuntime(String)Host/runtime-facing failure
LyquidError::LyquidRuntime(String)Application/runtime failure in Lyquid code
LyquidError::InputCertCertified call input/certificate is invalid
LyquidError::OracleError(String)Oracle-related failure

Constructor

A Lyquid constructor is optional. If present, it must be a network function named constructor.

#[method::network(export = eth)]
fn constructor(ctx: &mut _, greeting: String) {
*ctx.network.greeting = greeting;
}

Constructor rules:

  • It must be named constructor.
  • It must not specify a return type or group = ....
  • It may take parameters like normal network functions.
  • It may specify export = eth.
  • It is invoked atomically once at deployment or code upgrade.

Triggers

Use trigger! to invoke an instance function asynchronously from either a network or instance function.

#[method::network]
fn update_price(ctx: &mut _, symbol: String, price: U256) -> LyquidResult<bool> {
ctx.network.prices.insert(symbol.clone(), price);
trigger!(refresh_cache(symbol: String = symbol), TriggerMode::Commit);
Ok(true)
}

A trigger can target a specific group with trigger!((feeds::prices) refresh_cache(symbol: String = symbol), TriggerMode::Commit). Available trigger modes are:

  • TriggerMode::Once(ms): run once after ms milliseconds; 0 is immediate
  • TriggerMode::Recurrent(ms): run repeatedly at the given interval
  • TriggerMode::Commit: run once after the current network function finishes successfully. This mode is only supported from network functions. After a node crash, some recently completed network calls may be re-executed, and their commit triggers may fire again.
  • TriggerMode::Stop: cancel a recurrent trigger

Context Fields & Mutability

The first parameter of a Lyquid Function is the generated context object. You can name that parameter however you like, but ctx is the recommended convention and the name used throughout these docs. The context gives the function access to caller metadata and the state views allowed for that function category. Common context fields are:

FieldMeaning
ctx.lyquid_idCurrent Lyquid ID
ctx.originOriginal caller
ctx.callerDirect caller
ctx.inputRaw, undecoded input bytes
ctx.networkNetwork state accessor
ctx.instanceInstance state accessor, available in instance ctx
ctx.node_idID of the hosting node, available in instance ctx

Network contexts do not contain node_id because network execution must not depend on the hosting node.

The context reference controls whether the generated context exposes mutable state.

CategoryContext ReferenceState Access
networkctx: &mut _Mutable ctx.network; mutable ctx.cert for certified calls
networkctx: &_Immutable ctx.network
instancectx: &mut _Immutable ctx.network; mutable ctx.instance
instancectx: &_Immutable ctx.network and ctx.instance
UPC preparectx: &_Immutable ctx.network; mutable ctx.cache
UPC requestctx: &mut _Immutable ctx.network; mutable ctx.instance; ctx.from and ctx.id available
UPC responsectx: &_Immutable ctx.network; mutable ctx.cache; ctx.from and ctx.id available

Use ctx: &_ for read-only functions. This prevents accidental writes and makes the function's contract obvious to readers.

Logs and Console Output

Like serverless functions, Lyquid provides a per-node virtual console for debugging. Use println! and print! for Lyquid console output, and eprintln! and eprint! for error output.

Similar to blockchain smart contracts, Lyquid supports log!(tag, value) to permanently log crucial records emitted by network functions during their deterministic execution. log! is similar in spirit to an EVM event.

Attribute Forms

Most functions use the plain #[method::network] or #[method::instance] form. Add group = ... when a function belongs to a non-default method group, add export = ... when it should be reachable through an external interface, and use upc(...) for UPC handlers. Grouped methods and UPC handlers are covered in Groups and UPC; this section is only a compact attribute reference. For complete macro syntax, see the method Rust docs.

FormMeaning
#[method::network]Network function in the default (main) group
#[method::network(export = eth)]Network function exported through Ethereum ABI metadata
#[method::network(group = foo::bar)]Network function in group foo::bar
#[method::network(group = foo, export = eth)]Grouped network function with ETH export
#[method::instance]Instance function in the default group
#[method::instance(export = eth)]Instance function exported through Ethereum ABI metadata
#[method::instance(export = http, method = "GET", path_prefix = "/api")]Instance function exported as an HTTP endpoint
#[method::instance(group = foo::bar)]Instance function in group foo::bar
#[method::instance(upc(prepare))]UPC callee-selection handler
#[method::instance(upc(request))]UPC request handler
#[method::instance(upc(response))]UPC response handler

A function can declare at most one export kind. export = eth and export = "eth" are both accepted; ETH export works for network and instance functions, and grouped ETH exports include the group in their metadata, so they remain addressable when the function is not in the default main group. export = http is instance-only and requires method = "..." and path_prefix = "/..."; see External Access for HTTP signatures and routing.