Skip to main content

Groups and UPC

To keep Lyquid extensible without complicating ordinary function syntax, functions in any category can be grouped by a group name.

By default, all functions are in the default function group named main. #[method::network] and #[method::network(group = main)] are equivalent. To choose another group, use group = xxx::yyy::zzz; group names are path-style identifiers separated by ::.

#[method::network(group = foobar)]
fn my_network_func(ctx: &mut _) -> LyquidResult<()> {
// `foobar` is the group name.
// ...
}

#[method::network(group = foo::bar)]
fn my_network_func(ctx: &mut _) -> LyquidResult<()> {
// `foo::bar` is the group name (with some hierarchy).
// ...
}

// The two functions below have the same name in the same group, so only one can exist:
#[method::network(group = main)]
fn main_group_func(ctx: &mut _) -> LyquidResult<()> { /* ... */ }
// or
#[method::network]
fn main_group_func(ctx: &mut _) -> LyquidResult<()> { /* ... */ }

Most functions stay in the default group. Other group names give functions special semantics and different call context capabilities based on the group prefix, often driven by different events. UPC below shows how one logical endpoint can be described by multiple functions with different roles.

Naming Uniqueness

A Lyquid method is identified by its category, group, and function name. A function name must be unique within the same category and group, but the same name may appear in several groups. The same group/name pair may also appear once as a network method and once as an instance method because network and instance methods use separate entry-point namespaces.

One exception is immutable network methods (ctx: &_): they also emit an instance-callable alias, so an immutable network method clashes with an instance method that has the same group and function name. This works because an immutable network method is a more restrictive form of instance-callable logic: it can read network state, but it cannot mutate network state or access instance state. In that sense, the instance alias is syntactic sugar for exposing the same read-only network behavior through the instance call path. Mutable network methods (ctx: &mut _) do not emit that alias.

Some group prefixes are reserved by the LDK. They change when a function is triggered and what its ctx exposes:

PrefixRoleDetails
upc::prepare, upc::request, upc::responseUPC phasesUniversal Procedure Call
oracle::certified::<topic>Used by network functions handling certified callsSequence Backend & Certified Calls
oracle::single_phase::<topic>Single-phase validationSequence Backend & Certified Calls
oracle::two_phase::<topic>Two-phase validationSequence Backend & Certified Calls

Universal Procedure Call

Universal Procedure Call (UPC) is an instance-side inter-Lyquid communication mechanism. Use it when one node needs direct responses from other nodes. UPC models a logical call as phase-specific handlers under one endpoint name. In this section, that endpoint name is multi_sign: its prepare handler chooses target nodes, its request handler handles each remote request, and its response handler processes replies.

The endpoint name is what the caller invokes; the UPC group prefix selects which phase is running. The important asymmetry is that request runs on the "server" node that handles the incoming request, but prepare and response run on the "client" node that initiated the UPC. That is why prepare and response do not get access to the server node's instance state: their ctx is limited to reading the Lyquid's network state and using a temporary ctx.cache for this UPC lifecycle. The cache lets the client-side response handler aggregate replies as they arrive.

Both request and response contexts expose ctx.from. In a request handler, ctx.from is the requester node; in a response handler, ctx.from is the responding node. Response contexts also expose ctx.id, a caller-side multicast ID shared by all response-handler invocations for the same UPC. Request contexts have an id field too, but it is currently unset; do not use request ctx.id for call identity.

The LDK's built-in oracle mechanism uses this directly: part of its user-facing API expands into prefixed UPC group functions for oracle-specific phases. The multi_sign sketch below is a smaller version of the same technique.

#[method::instance(group = upc::prepare)]
fn multi_sign(ctx: &_) -> LyquidResult<Vec<NodeID>> {
// `prepare` is triggered when a UPC is initiated to determine which nodes
// to request their signatures from.
//
// This function is more like a read-only network function because the
// caller cannot access server instance state.
}

#[method::instance(group = upc::request)]
fn multi_sign(ctx: &mut _, tx: Transaction) -> LyquidResult<Option<Signature>> {
// `request` is triggered when a UPC request is made by a node, so the
// function handles the request.
// It is a full-fledged instance function at the server node.
}

#[method::instance(group = upc::response)]
fn multi_sign(ctx: &_, response: LyquidResult<Option<Signature>>) -> LyquidResult<Option<Proof>> {
// `response` is triggered when a UPC response is collected from a node.
// This function is called each time a node's response becomes available (order not guaranteed),
// so the caller can progressively decide if the final result is ready (Some) or not (None).
//
// ctx.cache offers temporary state for aggregation across invocations of this function.
}

To make this simpler and more readable, the LDK provides attribute shorthand on instance functions. The macro expands these forms to the standard group prefixes during the build:

#[method::instance(upc(prepare))]
fn multi_sign(ctx: &_) -> LyquidResult<Vec<NodeID>> {
// ...
}

#[method::instance(upc(request))]
fn multi_sign(ctx: &mut _, tx: Transaction) -> LyquidResult<Option<Signature>> {
// ctx.from carries the requesting node's NodeID.
// ...
}

#[method::instance(upc(response))]
fn multi_sign(ctx: &_, response: LyquidResult<Option<Signature>>) -> LyquidResult<Option<Proof>> {
// ctx.from carries the responding node's NodeID.
// One can access ctx.cache to track response-processing progress.
}

UPC is a specialization of the instance category, where functions with the same function name in different groups are triggered at various phases, and the context capabilities (ctx) are restricted differently.

The Three Phases

The mechanics are easiest to see with a fully implemented small endpoint. A UPC endpoint can define up to three phase handlers. This ping example uses all three. The endpoint name is split across prepare, request, and response: prepare selects recipient nodes, request runs on each recipient, and response turns replies into the caller's result. This version multicasts a message and completes with the first successful pong reply.

Prepare: choose required callee nodes

#[method::instance(upc(prepare))]
fn ping(ctx: &_) -> LyquidResult<Vec<NodeID>> {
Ok(ctx.network.nodes.clone())
}

Prepare handlers read network state and return the nodes required for the UPC. They can also set ctx.cache to initialize aggregation result for this UPC instance.

Request: run the request on each selected callee

#[method::instance(upc(request))]
fn ping(ctx: &mut _, msg: String) -> LyquidResult<String> {
let from = ctx.from;
Ok(format!("pong: {msg} from {from:?}"))
}

Request handlers run on selected callee nodes. The request function must take ctx: &mut _, followed by the request parameter(s) encoded by the UPC call, and return LyquidResult<T>. The return type T can differ from the request parameter types, and should match the response handler's parameter type. The request handler can read network state and mutate local instance state.

Response: progressively aggregate responses for a result

#[method::instance(upc(response))]
fn ping(ctx: &_, response: LyquidResult<String>) -> LyquidResult<Option<String>> {
let response = response?;
Ok(Some(response))
}

Response handlers aggregate returned values. The response function must take ctx: &_, followed by exactly one parameter of type LyquidResult<T>, where T should match the response type returned by the request handler. It returns LyquidResult<Option<R>>, where R can be different from T.

Use ctx.cache to keep aggregation state across response-handler invocations. A response handler typically updates ctx.cache for each reply, returns None while the final result is not ready, and returns Some(result) once enough replies have arrived. When the handler returns Some, the UPC process ends early and drops any future responses from nodes.

If no response handler exists, UPC behaves like a remote procedure call (RPC) that returns on the first result.

Initiate UPC

To initiate a UPC call from an instance function, use the upc! macro:

let result = upc!((callee).multi_sign(tx: Transaction = my_tx) -> (proof: Option<Proof>))?;
/* result.proof is the output */

let pong = upc!((callee).ping(msg: String = "hello".to_string()) -> (reply: String))?;
/* pong.reply is the first successful pong */

Here callee can be any expression that yields a LyquidID identifying the Lyquid that handles the UPC on the server side. Replace multi_sign or ping with the function name for that UPC call site.

The call mimics a Rust function declaration, with parameters separated by commas. Each parameter uses name: Type = value to supply the expression for that parameter's value. The return value needs to have a name (proof or reply above) so it can be accessed when the call completes.

You can optionally add square brackets [...] after the function name to pass client-side arguments to the prepare function, using the same name: Type = value syntax. The prepare function declares them as additional parameters after ctx:

// Call site: select the signer nodes based on an urgency hint.
let result = upc!((callee).multi_sign[urgent: bool = true](tx: Transaction = my_tx) -> (proof: Option<Proof>))?;
#[method::instance(upc(prepare))]
fn multi_sign(ctx: &_, urgent: bool) -> LyquidResult<Vec<NodeID>> {
// Use `urgent` (and network state) to decide which nodes to contact.
// ...
}

This keeps node selection fully programmable: the caller provides hints at the call site, while the prepare function combines them with network state to produce the recipient set. The current macro sends calls to the default UPC group; lower-level host APIs accept an optional group string when grouped dispatch is needed.

A Full Flow

To summarize, here is the full step-by-step lifecycle for a UPC call:

  1. A caller, such as another Lyquid, the handling Lyquid itself, a client node, or a wallet-facing application, starts a UPC with a function name and input parameters.
  2. Lyquor encodes the parameters with Lyquor ABI and forms an efficient binary request message.
  3. The client node's VM invokes the prepare function, passing any client-side arguments supplied at the call site. The result becomes the recipient set: the server nodes that should receive the request.
  4. Each selected server node receives the message and routes it to the corresponding request function for the Lyquid. When that function returns, the server node sends the response back to the client node.
  5. As each response arrives, the client node routes it to the response function. The response handler decides whether the final result is already available or whether more responses are needed. Once it returns Some, aggregation ends, that value becomes the UPC output, and the caller's asynchronous wait completes.