Skip to main content

Function Group and UPC

To make Lyquid extensible without introducing syntax complication, we allow functions in all categories, to be grouped by a group name.

By default, all functions are in the default function group named main. To explicitly specify the group name of a function, use the group = xxx::yyy::zzz attribute argument. The group name is in 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 identical names, thus 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<()> { /* ... */ }

For most of the time, the default group works. The other group names give functions special semantics and call context (ctx functionalities) based on the group prefix, driven by different events.

To support Universal Procedure Call (UPC) natively in Lyquid, we use different UPC roles for the same method name. Imagine you would like to implement a method named multi_sign for the call site, and three functions with prepare, request, and response roles describe the handling logic for different phases of the call, respectively.

Function groups allow clear identification of the call site ("multi_sign"), with the capabilities to differentiate various events that could happen to the same concept. The only problem is for the prepare and response phases, they are not run by the "server" node that handles the request, but the "client" (i.e., the request initiator node) that prepares the request and consumes the responses. Therefore, the context provided by ctx is limited to reading the Lyquid's network state, and a special, temporary cache that lives only through the call lifecycle can be used to aggregate the responses.

#[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's 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 it simpler and more readable, UPC uses role-specific attributes on instance functions:

#[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>> {
// ...
}

#[method::instance(upc(response))]
fn multi_sign(ctx: &_, response: LyquidResult<Option<Signature>>) -> LyquidResult<Option<Proof>> {
// One can access ctx.cache to bookkeep the progress of response processing.
}

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

To better understand when a UPC call engages these functions:

  1. The user (could be from the wallet, a Lyquid, or the handling Lyquid itself) starts a UPC with the method name and the input parameters.
  2. Lyquor encodes the parameters using its ABI efficiently and form a binary message.
  3. If the caller doesn't manually specify the nodes that handle the call, the VM then automatically invokes the prepare function whose result is used as the set of nodes as recipients.
  4. When a node receives the message, it will route the UPC request to the corresponding request function for the Lyquid, when the function returns, the response will be messaged back to the caller.
  5. Upon receiving one response from a node, it is routed to the correct response function that's run by the caller's VM to determine whether the final result is already available, or the caller needs more. Once the function gives a Some value, the aggregation process ends, and the value is used as the output to end the asynchronous wait of the caller.

Finally, to initiate a UPC call within a Lyquid, one uses the upc! macro in a way like:

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

Here callee can be any expression that yields LyquidID that identifies the Lyquid that's called to handle the UPC as the "server" side. multi_sign should be replaced with the method name in this call. A pair of square brackets [...] can be optionally added after the method name to choose the version of the network state that the called Lyquid will use to handle the call (see LyquidNumber). It can be omitted if the latest known to the node should be used (This is a bit similar to using "latest" as the block number when you make a eth_call for an EVM chain).

The rest of the call mimicks a function declaration in Rust, with parameters separated with comma, where each parameter needs to end with an assignment statement = to supply the expression of the parameter's value. The return value needs to have a name (proof in this example) so it can be accessed when the call completes.