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 the call carries application-defined policy that must be verified before the sequence backend admits it. 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 adds a Lyquor-level certificate on top of the backend's normal sequencing rules. In blockchain terminology, this second path is typically called an oracle flow: the certified call packages externally verified facts for a sequence backend that cannot derive those facts by itself. The developer is responsible for application-level policy validation; the LDK handles protocol-level signing, signature verification, certificate aggregation, and target binding.
Oracle Topics
Declare a certified-call topic with network oracle <name>;:
state! {
// Oracle topic variable.
network oracle price;
// Auxiliary regular variables for this price feed.
network last_price: U256 = U256::ZERO;
instance local_price: U256 = U256::ZERO;
}
The declaration creates built-in source/target oracle state and a generated topic handle at
ctx.network.price. In Lyquid code, that handle is the source-side API for staging committee
changes, reading the source's last synchronized view, and producing certified calls through
certify or propose_and_certify. The LDK maintains target-side verifier state for the same topic
when certified calls settle.
A topic is a validation namespace. OracleTarget names the target side in code:
OracleTarget.seq_id: the sequence backend ID.OracleTarget.target: the service destination, eitherOracleServiceTarget::LVM(LyquidID)orOracleServiceTarget::EVM { target, eth_contract }.
The source side is the Lyquid and 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 target-settled configuration, but it is not the final authority. The target side is the sequence backend and destination service that keeps the canonical active configuration for verification, accepts or rejects certificates, and executes certified network calls. The target can be the source Lyquid or another Lyquid, and it can live on the same or a different sequence backend. In all cases, certificates are bound to one exact topic, target, active committee configuration, call envelope, and LDK-managed security metadata.
Configuring a Committee
A certified call can form only after the topic has an active committee for the target. The LDK does not decide who is allowed to initialize that 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, and reject later attempts to
replace the initial committee unless your Lyquid deliberately implements governance for that.
initialize stages the first committee; it does not immediately make the target active. The staged
configuration becomes active after it is certified to the target and synchronized back to the
source. The same lifecycle applies to later committee changes: after a target is active, add_node,
remove_node, and set_threshold stage updates for the next active configuration.
Protect committee updates with the same kind of application authority as initialization. That
authority does not have to remain deployer-only; Governance Calls shows how
the current committee can certify later membership changes. Use config_current for the active
committee used by certification, and config_staging for the committee after staged changes. Until
the staged configuration is active, ordinary certified calls continue to use the current active
configuration. If a reconfiguration requires multiple edits to be atomic, stage them inside one
authorized network function; once the staged configuration is valid, activation is permissionless.
The LDK calls each active oracle configuration an epoch. Advancing the epoch means applying staged changes as the next active configuration. 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 staged changes, run the generated exported oracle activation helpers. The LDK provides
these built-in helper functions automatically. They are instance functions: each helper starts the
required committee vote, and if the vote succeeds, submits the certified update that changes target
or source network state. In other words, these helpers do not return a CallParams for your code
to submit; they build the certified CallParams internally and submit it through the host API:
__lyquor_oracle_advance_epoch(...)certifies the staged source changes and submits a target-side update to the target verifier state.__lyquor_oracle_finalize_epoch(...)certifies the target-settled configuration back to source and updates the source-side synchronized view.
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-settled 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. The agent-consensus UI calls configure_committee as a normal
transaction, then invokes __lyquor_oracle_advance_epoch(...), polls
__lyquor_oracle_dest_epoch_info(topic, false) until the target update is visible, and finally
invokes __lyquor_oracle_finalize_epoch(...).
These helpers are intentionally public liveness hooks. Safety does not come from the caller's identity; it comes from generated certificate validation. The configuration updates they submit must match staged source changes or target-settled configuration facts, obtain the configured threshold signatures, and pass target binding, active configuration, and LDK-managed safety checks before any oracle state changes.
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 can be trusted by all nodes running the target Lyquid. 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 service destination
- 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.
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.
By default, 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 destination and call envelope your Lyquid is willing to certify:
target: the sequence backend ID and service destination.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 != 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 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)
}
Governance Calls
The trusted setup path is only for the first committee. Later committee changes do not have to use the same deployer-style authority. A Lyquid can define a governance route under the same oracle topic, where the current committee certifies a call that lands back on the source Lyquid and stages the next committee update.
This is useful when membership should be decided by application policy: uptime, response latency,
reputation, host entity, identity checks, stake, service history, manual operator approval, or a
quorum decision produced by propose_and_certify. This lets membership be self-governed by the
rules written into the Lyquid itself. The certified governance call is still just a network
function. Its body can call add_node, remove_node, or set_threshold, which stages the change
for the next oracle configuration.
The governance route is just a suffixed certified route. For single-phase governance, define the
matching validation hook under a suffix such as oracle::single_phase::price::governance. For
two-phase governance, define propose under a suffix such as oracle::two_phase::price::governance;
the aggregate hook still lives at oracle::two_phase::price and should use ctx.init to select
the governance aggregation logic when needed.
#[method::network(group = oracle::certified::price::governance)]
fn admit_node(ctx: &mut _, governed: OracleTarget, candidate: NodeID) -> LyquidResult<bool> {
let oracle = ctx.network.price.clone();
Ok(oracle.add_node(&mut ctx, governed, candidate))
}
A submitter certifies this with the governance group suffix and a target that points to the
source Lyquid, usually OracleServiceTarget::LVM(ctx.lyquid_id) on the current sequence backend.
The governed argument is the target committee being changed. For the simple self-target case,
the source target and governed target are the same. For cross-target designs, the governance call
lands on the source Lyquid, and the network function stages changes for the destination target
passed in the certified call input.
After the governance 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.
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.
Advanced Feature: Target Kinds
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 when your application is designed to call across
Lyquids or backends.
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 seamless cross-chain settlement, where
a Lyquid-certified decision needs to land in an existing EVM contract. It deliberately leaves the
normal Lyquid execution flow. For ordinary certified calls between Lyquids, prefer
OracleServiceTarget::LVM(...).