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:
| Prefix | Role | Details |
|---|---|---|
upc::prepare, upc::request, upc::response | UPC phases | Universal Procedure Call |
oracle::certified::<topic> | Used by network functions handling certified calls | Sequence Backend & Certified Calls |
oracle::single_phase::<topic> | Single-phase validation | Sequence Backend & Certified Calls |
oracle::two_phase::<topic> | Two-phase validation | Sequence 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:
- 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.
- Lyquor encodes the parameters with Lyquor ABI and forms an efficient binary request message.
- The client node's VM invokes the
preparefunction, passing any client-side arguments supplied at the call site. The result becomes the recipient set: the server nodes that should receive the request. - Each selected server node receives the message and routes it to the corresponding
requestfunction for the Lyquid. When that function returns, the server node sends the response back to the client node. - As each response arrives, the client node routes it to the
responsefunction. The response handler decides whether the final result is already available or whether more responses are needed. Once it returnsSome, aggregation ends, that value becomes the UPC output, and the caller's asynchronous wait completes.