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, bracket the group name right after the category:

network(foobar) fn my_network_func(&mut ctx) -> LyquidResult<()> {
// `foobar` is the group name.
// ...
}

// Two functions below have the identical names, thus one one of them can exists:
network(main) fn main_group_func(&mut ctx) -> LyquidResult<()> { /* ... */ }
// or
network fn main_group_func(&mut ctx) -> LyquidResult<()> { /* ... */}

For most of the time, the default group works. The other group names give functions special semantics based on the event that triggers them.

To support Universal Procedure Call (UPC) natively in Lyquid, we use different group names for the same given method name. As a thought experiment, let's image the method name to be "multi_sign" for the call site, whereas three functions in each of upc_callee, upc_request, and upc_response groups describe the customized handling logic for different phases of a UPC call, resprectively.

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 upc_callee and upc_response, they are not run by the "server" node that handles the request, but the "client" side 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 cache that can be used to aggregate the responses.

/// NOTE: The code below is just the sketch of the idea, not the final UPC syntax.

instance(upc_callee) fn multi_sign(&ctx) -> LyquidResult<Vec<NodeID>> {
// `upc_callee` is triggered when UPC is initiated to determine which nodes
// to request their signatures resprectively...
//
// This function is more like a read-only network function because the
// client cannot access server's instance state. So `instance` here is inappropriate.
}

instance(upc_request) fn multi_sign(&mut ctx, tx: Transaction) -> LyquidResult<Option<Signature>> {
// `upc_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.
}

instance(upc_response) fn multi_sign(&ctx, response: LyquidResult<Option<Signature>>) -> LyquidResult<Option<Proof>> {
// `upc_response` is triggered when a UPC response is collected from a node.
// This function is called each time a node's response becomes available (the order is not guaranteed),
// so the client side should progressively see if the final result could be drawn (return Some) or not (None)...
//
// ctx.cache here will offer a temporary state for the aggreation work across the invocations of this function.
// Other than that, the function is more like a read-only network function
// because the client cannot access server's intance state. So `instance` here is inappropriate.
}

To avoid confusion and make the semantics of network/instance categories stay the way they are, we introduced a upc category that captures this UPC-specific behavior. The functions can then be written as:

upc(callee) fn multi_sign(&ctx) -> LyquidResult<Vec<NodeID>> {
// ...
}

upc(request) fn multi_sign(&mut ctx, tx: Transaction) -> LyquidResult<Option<Signature>> {
// ...
}

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 further refinement of instance category, where functions with the same method name in different groups are triggered at various phases, and the context capabilities (ctx) are different.

To better understand when a UPC call engage 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 method in callee group 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 aggreation 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. The square bracket chooses the version of the network state that the called Lyquid will use to handle the call (see LyquidNumber) It can be left out as None 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.