Sequence Backend & Certified Calls
So far, the LDK docs have shown the one-way data path from network state into instance logic: network functions are driven by the sequence backend, can mutate network state, and can trigger instance functions, while instance functions can read network state but cannot mutate it directly. To close the loop, an instance must be able to submit a network call to a sequence backend.
A sequence backend is the ordering substrate for network calls. It does not have to be a blockchain: cloud infrastructure also uses protocols and services such as Paxos, Raft, and ZooKeeper to give a distributed system one consistent sequence of operations. Lyquor treats blockchains, replicated logs, and future non-chain ordering services as different sequence backends behind the same conceptual role.
There are two ways to sequence a network call:
- Generic network call submission: used for ordinary network functions submitted through the
sequence backend's native entrypoint. On the current EVM-compatible sequence backend, that
entrypoint is exposed with
export = eth. - Certified call submission: used when application-defined policy must be checked before the target application logic runs. A developer-configured threshold of a committee selected from nodes hosting the Lyquid authenticates the call, and the resulting certificate is submitted with the network call.
For generic submission, admission comes from the sequence backend's native authority model. In a
conventional cloud deployment, that admission is often centralized and hard-coded into the service
or control plane. On a blockchain backend, the comparable path is authenticated by the transaction
signer, such as the sender or wallet, and state manipulation is isolated by msg.sender, as in
ERC20 transfers or AMM swaps.
Certified submission does not replace the backend's normal sequencing rules; it adds a Lyquid-level committee certificate to the call before the backend sequences it. In blockchain terminology, this second path is typically called an oracle flow: the certified call packages externally verified facts that the sequence backend cannot derive by itself. The certificate means that the developer-configured threshold of committee nodes has run the application-level policy validation you define and approved the exact call being submitted. From there, the LDK handles the protocol layer end to end: it collects and aggregates the committee signatures into a certificate, binds that certificate to the intended target, verifies it in the target-side wrapper, and only then routes the call into your certified network function.
Oracle Topics
Use an oracle topic to name a validation and policy domain inside a Lyquid. A topic is broader than
one certified function: multiple certified call sites, each with its own target function name, can
use the same topic when they should be approved under that topic's policy. In the source Lyquid,
the topic handle keeps source-side state separately for each target you configure, so one topic can
stage and certify calls for different targets. A Lyquid can also declare multiple topics; within a
topic, each configured target can use its own committee subset from the nodes
hosting the source Lyquid. Declare a topic with network oracle <name>;:
state! {
// Oracle topic network variable. The LDK initializes its built-in oracle state,
// so it does not need an explicit type or initializer.
network oracle price;
// Application state that certified price calls will read or update.
network last_price: U256 = U256::ZERO;
instance local_price: U256 = U256::ZERO;
}
The declaration adds a generated network field at ctx.network.price. That field is the
source-side topic handle: in Lyquid code, it lets you stage committee changes for a target, read
the source's last synchronized view for that target, and produce certified calls through certify
or propose_and_certify. On the receiving side, the LDK manages verifier state automatically, so a
Lyquid does not need to declare a matching network oracle variable just to be a target.
The source side is the source Lyquid and its sequence backend, where committee changes are staged and where nodes hosting that Lyquid collect observations, validate policy, and produce certificates. Source state keeps a synchronized view of the last configuration accepted by the target, but it is not the final authority. When the target is a Lyquid, the target side is the target Lyquid and its sequence backend, which keeps the canonical active configuration for verification, accepts or rejects certificates, and executes certified network calls. The target Lyquid can be the source Lyquid or another Lyquid, and its sequence backend can be the same as or different from the source backend. In all cases, certificates are bound to one exact sequence backend, topic, target, active committee configuration, call envelope, and LDK-managed security metadata.
Configuring a Committee
A topic can produce certified calls for a target only after the source-side configuration for that target has been activated on the target and synchronized back to the source. The source Lyquid first stages a committee for the topic and target under application-defined authority. Then the LDK activation helpers certify and submit that staged configuration to the target, and synchronize the target-accepted configuration back to the source.
The LDK does not decide who is allowed to stage the first committee. That is part of your Lyquid's trusted setup policy. A common pattern is to record the deployer or initializer in the constructor, then require that address before staging the first committee:
state! {
network oracle price;
network initializer: Address = Address::ZERO;
network last_price: U256 = U256::ZERO;
instance local_price: U256 = U256::ZERO;
}
#[method::network(export = eth)]
fn constructor(ctx: &mut _) {
*ctx.network.initializer = ctx.caller;
}
#[method::network(export = eth)]
fn configure_price_oracle(ctx: &mut _, committee: Vec<NodeID>) -> LyquidResult<bool> {
if ctx.caller != *ctx.network.initializer {
return Err(LyquidError::LyquorRuntime("only initializer can configure oracle".into()));
}
if committee.is_empty() || committee.len() > u16::MAX as usize {
return Err(LyquidError::LyquorRuntime("invalid oracle committee".into()));
}
let target = OracleTarget {
seq_id: lyquor_api::sequence_backend_id()?,
target: OracleServiceTarget::LVM(ctx.lyquid_id),
};
let threshold = (committee.len() / 2 + 1) as u16;
let oracle = ctx.network.price.clone();
if !oracle.config_current(&ctx, target).committee.is_empty()
|| !oracle.config_staging(&ctx, target).committee.is_empty()
{
return Err(LyquidError::LyquorRuntime("oracle committee already configured".into()));
}
if !oracle.initialize(&mut ctx, target, committee, threshold) {
return Err(LyquidError::LyquorRuntime("oracle initialization failed".into()));
}
Ok(true)
}
Do not expose initialize as an open public action. Route it through a trusted setup path, such as
a deployer-gated constructor/setup function like the example above. initialize only stages the
first committee; it does not immediately make the target active. Until that first configuration is
activated and synchronized back to the source, repeated source-side initialize calls can replace
the staged committee, so your setup path should prevent that. After a current committee is active,
the runtime rejects another initialization.
Later membership changes should go through explicit update functions. After the initial activation
has synchronized back to the source, authorized network functions can call add_node,
remove_node, or set_threshold to stage the next configuration. As with the initial setup
function, each update function should enforce the authority your Lyquid wants for that change.
Governance Calls shows a common pattern where the update function itself is
reached through a certified call.
config_current and config_staging read the source-side state for a topic and target. Use
config_current to inspect the active committee currently used for certification, and
config_staging to inspect the candidate next committee that would result if the staged changes are
activated. Until staged changes are activated, ordinary certified calls keep using the current
active configuration. If a reconfiguration needs multiple edits, stage the complete intended change
together in one authorized network function. Once a staged change exists, anyone can submit it for
committee certification. The target update takes effect only if the required committee certificate
is produced and accepted.
The LDK calls each active oracle configuration an epoch. Advancing the epoch means applying staged changes as the next active configuration. The initial committee becomes active the same way: it is certified to the target and then synchronized back to the source. This activation is itself an LDK-defined certified call, so oracle configuration changes use the same committee-certificate mechanism as application-level certified calls.
To activate the initial committee or a later staged change, run the generated exported oracle
activation helpers. The LDK provides these built-in instance functions automatically. Each helper
starts the required committee vote and, if the vote succeeds, builds the certified CallParams
internally and submits it through the host API. The activation sequence is:
- Call
__lyquor_oracle_advance_epoch(topic, target_addr, is_evm)to certify the staged source changes and submit them to the target's verifier state. - After the target accepts the update, call
__lyquor_oracle_finalize_epoch(topic, target_addr, is_evm)to synchronize the source-side view with the target-accepted configuration.
For the usual Lyquid-target case, topic is the oracle variable name such as "price",
target_addr is the target Lyquid ID encoded as an address, and is_evm is false. Tooling or UI
code can wrap this sequence. On the current EVM-compatible sequence backend, these helpers appear in
the generated Ethereum ABI. After submitting the advance step, tooling can poll the target Lyquid's
__lyquor_oracle_dest_epoch_info(topic, false) until the target update is visible, then call the
finalize step.
These helpers are intentionally public liveness hooks. Safety does not come from the caller's identity; it comes from generated certificate validation. The submitted update must match the staged source changes or target-accepted configuration facts, obtain the configured threshold signatures, and pass target binding, configuration, and LDK-managed safety checks before any oracle state changes.
The first target-side activation is the bootstrap trust boundary for a topic and target. It establishes the target's authoritative committee for that topic. After that, certified calls and committee changes are verified against the target-side active configuration. Before that first activation, the target has no active committee to check against, and the runtime's epoch-advance path is internal rather than a target application hook. Because the LDK cannot infer which source Lyquid a target intended to trust, especially for cross-Lyquid targets, treat the first activation as operational trusted setup and make sure the intended activation is accepted first. Signatures or capabilities inside later certified call inputs can protect those target functions, but they cannot authorize the first activation itself.
If the topic has no active valid configuration for the target, certify and propose_and_certify
return Ok(None).
Styles of Certified Calls
The goal of a certified call is to turn node-local observations and policy checks into a network call that the target can verify before running application logic. Committee nodes do not approve an abstract "price" or "event" alone. They approve one concrete call envelope together with the target facts the LDK will verify:
- the target sequence backend and concrete target
- the target's active committee configuration
- the function name, encoded input, origin, and caller for the certified network call
- LDK-managed security metadata
CertifiedCallParams is the developer-supplied part of that call:
CertifiedCallParams {
origin: Address::ZERO,
method: "update".into(),
input: encode_by_fields!(new_value: U256 = value).into(),
target,
}
The LDK fills the rest of the call envelope, certificate, and signature aggregation details.
CallParams is the general call envelope that a sequence backend accepts for sequencing.
CertifiedCallParams is the smaller developer-supplied description of the target call. The oracle
flow uses committee approval to turn a CertifiedCallParams, either supplied directly or produced
by aggregation, into a CallParams that carries the completed certificate and is ready to submit to
the sequence backend. CallParams itself does not have an OracleTarget field; the oracle target
is carried in CertifiedCallParams during certification and in the certificate header after
certification.
There are two certification styles:
certify: ask the committee to approve aCertifiedCallParamsthat the proposer already built.propose_and_certify: collect node inputs, aggregate them intoCertifiedCallParams, then ask the committee to approve that call.
For Lyquid targets, a single-phase certified call lands on the target network function under
oracle::certified::<topic>. A two-phase certified call lands under
oracle::certified::<topic>::two_phase. If you pass a group suffix, append it to the target route.
For single-phase calls, also append it to the validation hook. For two-phase calls, append it to
propose; aggregate remains the topic-level hook because the LDK re-runs it during validation.
The protocol flow is automated. The LDK generates the signing, signature collection, certificate packing, target verification, and submission plumbing. As the Lyquid developer, you define the application hooks with attribute-tagged functions: what makes a proposed call valid, and, for two-phase flows, how node inputs should be aggregated into the final target call.
Single-Phase Validation
Use single-phase validation when the proposer already knows the target function input it wants to submit, such as one price update, observed event, or API result. Each committee node that runs the Lyquid receives that proposed call and executes the same validation function. Approvals may still differ because each node can have different instance state or local observations. This is the point of Lyquid's network/instance design: the code path is fixed and verified, while the node-local inputs to that code can legitimately vary.
Single-phase validation receives the proposed CallParams plus optional extra: Bytes. The
certificate forms only from threshold approvals over the same proposed call. extra is
proposer-supplied validation evidence only; it is not submitted to the target. If the proposer must
not be able to show different extra bytes to different committee nodes, put the hash of extra
in CallParams.input, so only approvals that checked the same evidence can reach the threshold.
Approve only the target and call envelope your Lyquid is willing to certify:
target: the sequence backend ID and concrete target.params.groupandparams.method: the certified route and network function.params.inputandparams.abi: the encoded arguments and how they are decoded.params.originandparams.caller: identities used by your application policy.extra: optional validation evidence; bind its hash intoparams.inputif the exact bytes must be fixed across committee approvals.
The LDK handles certificate checks and aggregates committee signatures. Your validation function only decides whether this node approves the proposed call.
#[method::instance(group = oracle::single_phase::price)]
fn validate(
ctx: &mut _,
params: CallParams,
_extra: Bytes,
target: OracleTarget,
) -> LyquidResult<bool> {
let expected_target = OracleTarget {
seq_id: lyquor_api::sequence_backend_id()?,
target: OracleServiceTarget::LVM(ctx.lyquid_id),
};
let expected_caller = Address::from(ctx.lyquid_id);
if target != expected_target
|| params.group != "price"
|| params.method != "update"
|| params.abi != lyquor_primitives::InputABI::Lyquor
|| params.origin != Address::ZERO
|| params.caller != expected_caller
{
return Ok(false);
}
let proposed = decode_by_fields!(¶ms.input, new_value: U256)
.map(|input| input.new_value);
Ok(proposed == Some(*ctx.instance.local_price.read()))
}
An instance function calls certify with one concrete CertifiedCallParams. Its arguments after
ctx are the proposed call, extra bytes for committee nodes, an optional group suffix, and
an optional timeout in milliseconds. This submitter is exported because it is the practical entry
point used by external tooling on the current EVM-compatible backend:
#[method::instance(export = eth)]
fn submit_value(ctx: &mut _, value: U256) -> LyquidResult<bool> {
let target = OracleTarget {
seq_id: lyquor_api::sequence_backend_id()?,
target: OracleServiceTarget::LVM(ctx.lyquid_id),
};
let oracle = ctx.network.price.clone();
let call = oracle.certify(
&mut ctx,
CertifiedCallParams {
origin: Address::ZERO,
method: "update".into(),
input: encode_by_fields!(new_value: U256 = value).into(),
target,
},
Bytes::new(),
None,
None,
)?;
if let Some(call) = call {
let _ = submit_certified_call!(call)?;
Ok(true)
} else {
Ok(false)
}
}
The certified call lands on the target network function under oracle::certified::<topic>:
#[method::network(group = oracle::certified::price)]
fn update(ctx: &mut _, new_value: U256) -> LyquidResult<bool> {
*ctx.network.last_price = new_value;
Ok(true)
}
Inside a target function, the generated ctx object includes ctx.cert. Use it when the network
function needs to record which committee signatures authorized the update:
let signers: Vec<NodeID> = ctx
.cert
.signers
.clone()
.into_iter()
.filter_map(|signer_id| ctx.signer_node_id(signer_id as u64))
.collect();
Two-Phase: Propose and Validate
Single-phase validation is intentionally direct: the proposer supplies the target function and input, and the committee decides whether to approve that exact proposed call. Many applications need one step before that. They first need to collect node-local observations or agent results, then aggregate those inputs to work out the actual input for the proposed certified call.
Two-phase certification captures that common pattern. It first runs a generated request/response
round across committee nodes to collect inputs, then feeds the aggregated CertifiedCallParams
into the same certification path used by single-phase certification. You still only provide
application hooks under oracle::two_phase::<topic>:
propose: runs on committee nodes and returns each node's local input.aggregate: runs on the proposer, and is re-run by committee nodes, to deterministically turn signed inputs into oneCertifiedCallParams.
propose receives parameters decoded from the init bytes passed to propose_and_certify.
Treat them as the proposal envelope and check the fields that determine whether this node should
contribute an input:
#[method::instance(group = oracle::two_phase::price)]
fn propose(ctx: &mut _, min_inputs: u16, target: OracleTarget) -> LyquidResult<U256> {
let expected_target = OracleTarget {
seq_id: lyquor_api::sequence_backend_id()?,
target: OracleServiceTarget::LVM(ctx.lyquid_id),
};
if target != expected_target || min_inputs == 0 {
return Err(LyquidError::OracleError("invalid proposal".into()));
}
Ok(*ctx.instance.local_price.read())
}
aggregate validates the same ctx.init envelope, checks the decoded node inputs, and constructs
the final CertifiedCallParams. Return Ok(None) to keep waiting or to decline certification.
Write it as a deterministic function of ctx.init and ctx.inputs:
#[method::instance(group = oracle::two_phase::price)]
fn aggregate(ctx: &_) -> LyquidResult<Option<CertifiedCallParams>> {
let init = decode_by_fields!(ctx.init, min_inputs: u16, target: OracleTarget)
.ok_or(LyquidError::LyquorInput)?;
let expected_target = OracleTarget {
seq_id: lyquor_api::sequence_backend_id()?,
target: OracleServiceTarget::LVM(ctx.lyquid_id),
};
if init.target != expected_target || init.min_inputs == 0 {
return Ok(None);
}
if ctx.inputs.len() < init.min_inputs as usize {
return Ok(None);
}
let mut prices = ctx
.inputs
.iter()
.map(|input| decode_object::<U256>(&input.input).ok_or(LyquidError::LyquorOutput))
.collect::<LyquidResult<Vec<_>>>()?;
prices.sort();
let median = prices[prices.len() / 2];
Ok(Some(CertifiedCallParams {
origin: Address::ZERO,
method: "update".into(),
input: encode_by_fields!(new_value: U256 = median).into(),
target: init.target,
}))
}
For the final call, aggregate controls the CertifiedCallParams fields: target, origin,
method, and input. The LDK derives the remaining CallParams fields: caller is the Lyquid,
group is the two-phase certified route, and abi follows the target kind.
Inside aggregate, ctx provides:
ctx.init: initial bytes passed by the proposerctx.inputs: signed inputs collected from nodesctx.lyquid_id: current Lyquid ID
In two-phase validation, the aggregation inputs are not arbitrary proposer-only extra. Committee
nodes return signed proposal inputs first. The LDK verifies those signed inputs and gives them back
to aggregate when the final call is validated, so the proposer cannot rewrite what a node
contributed or present different versions during certification.
Because aggregate is re-run by the LDK during validation, keep it as an internal instance
function with exactly the ctx: &_ parameter and return LyquidResult<Option<CertifiedCallParams>>.
It should not take extra parameters or use export = eth; all inputs come from ctx.init and
ctx.inputs.
propose_and_certify first collects node inputs through propose, then lets aggregate build the
final CertifiedCallParams, then runs the same validation/certification path. The caller supplies
only the target, init bytes, optional group suffix, and optional timeout:
#[method::instance(export = eth)]
fn submit_value_aggregated(ctx: &mut _) -> LyquidResult<bool> {
let target = OracleTarget {
seq_id: lyquor_api::sequence_backend_id()?,
target: OracleServiceTarget::LVM(ctx.lyquid_id),
};
let oracle = ctx.network.price.clone();
let call = oracle.propose_and_certify(
&mut ctx,
target,
encode_by_fields!(min_inputs: u16 = 3, target: OracleTarget = target).into(),
None,
None,
)?;
if let Some(call) = call {
let _ = submit_certified_call!(call)?;
Ok(true)
} else {
Ok(false)
}
}
The resulting certified call lands on the target network function under the two_phase route:
#[method::network(group = oracle::certified::price::two_phase)]
fn update(ctx: &mut _, new_value: U256) -> LyquidResult<bool> {
*ctx.network.last_price = new_value;
Ok(true)
}
Submitting the Result
Both certify and propose_and_certify return Ok(Some(call)) only after enough committee nodes
approve the certified call. Submit that CallParams to the sequence backend with
submit_certified_call!(call).
A None result means the call was not certified: for example, the topic is not active for the
target, the current config is invalid, the threshold was not reached, or committee nodes rejected
the call.
The default submit_certified_call!(call) form relies on the node to sign the backend submission.
The two-argument submit_certified_call!(call, signed) form passes an explicit signed boolean to
the host API.
Governance Calls
After initialization is complete, later committee updates are source-side network functions that
stage changes with add_node, remove_node, or set_threshold. Authorization is
application-defined: a normal authenticated call can check a signer, role, or account, while a
certified call can let committee agreement authorize the update through validation hooks and a
committee certificate. The LDK adds no separate governance primitive. Authority comes from the
network function's checks and, for certified calls, from the certificate attached to the call.
Use certified updates when membership depends on application policy: uptime, response latency, reputation, identity checks, stake, manual operator approval, or any threshold agreement by the committee. The policy can use the same oracle topic with a different certified function name, or a separate oracle topic whose active committee authorizes changes to this topic's configuration for a target. In either design, the certified call should target the source Lyquid, because source-side network state is where committee changes are staged.
The function below is only one example. Your validation hooks decide the real payload shape and should approve only the membership changes your policy allows. In particular, the validation path should check that the certified call lands on the source Lyquid and that the candidate is approved for the target configuration being changed.
#[method::network(group = oracle::certified::price)]
fn admit_node(ctx: &mut _, candidate: NodeID) -> LyquidResult<bool> {
let source = OracleTarget {
seq_id: lyquor_api::sequence_backend_id()?,
target: OracleServiceTarget::LVM(ctx.lyquid_id),
};
let oracle = ctx.network.price.clone();
Ok(oracle.add_node(&mut ctx, source, candidate))
}
A submitter certifies this like any other certified call, using method: "admit_node" and a target
that points to the source Lyquid, usually the source Lyquid's LVM target on the current sequence
backend. This example stages a change for the self-target case, where the source Lyquid is also the
oracle target being changed. For cross-target designs, keep the certified call landing on the source
Lyquid, but include a separate target OracleTarget in the certified call input and stage changes
for that target.
After the call is sequenced, the change is still only staged. Activate it with the same
__lyquor_oracle_advance_epoch(...) and __lyquor_oracle_finalize_epoch(...) helper sequence.
Advanced Feature: Target Kinds
In code, OracleTarget is the fully qualified target for a certified call:
OracleTarget.seq_id is the sequence backend ID, and OracleTarget.target carries the target kind
and its target-specific identifier. The generated helpers and examples in this page set seq_id to
the sequence backend where the helper is running.
Most LDK users should use an LVM target. In code, LVM means Lyquor VM:
let target = OracleTarget {
seq_id: lyquor_api::sequence_backend_id()?,
target: OracleServiceTarget::LVM(ctx.lyquid_id),
};
With an LVM target, the certified call is checked by the target Lyquid's oracle state and then
executed as a Lyquid network function, such as
#[method::network(group = oracle::certified::<topic>...)]. This is the native path for Lyquid
applications: the sequence backend orders the call, while the Lyquid runtime verifies the
certificate and runs the target network function. The target Lyquid can be the same Lyquid that
produced the certificate, or another Lyquid on the selected sequence backend.
OracleServiceTarget::EVM { target, eth_contract } is for EVM interop, not the default Lyquid
path. Use it only when the certified call should land on a non-Lyquid EVM-native contract on an
EVM-compatible sequence backend. In that target kind:
targetis the final EVM contract your certified call is meant to reach.eth_contractis the oracle verifier/entry contract on that EVM backend. It receives the certificate and is the contract expected to dispatch the call totarget.methodis the EVM contract function signature, not a Lyquid network function name.inputis ABI-encoded for that contract, and the LDK marks the call with the EVM ABI.
This EVM path is useful for bridging-style integrations and EVM-native settlement, where a
Lyquid-certified decision needs to land in an existing EVM contract. It deliberately leaves the
normal Lyquid execution flow. For certified calls between Lyquids, prefer
OracleServiceTarget::LVM(...).