Oracles and Certified Calls
Oracle support lets instance-side validation produce certificates that authorize network-side calls. The developer is responsible for application-level policy validation; the LDK handles protocol-level signing, signature verification, certificate aggregation, and target binding.
Oracle State
Declare oracle state with a topic name:
state! {
network oracle price;
network last_price: U256 = U256::ZERO;
instance local_price: U256 = U256::ZERO;
}
The network oracle price; declaration creates built-in source/destination oracle state for the
topic and generated epoch-management handlers.
Certified Network Methods
Certified methods are network methods under oracle::certified::<topic>.
#[method::network(group = oracle::certified::price, export = eth)]
fn update(ctx: &mut _, value: U256) -> LyquidResult<bool> {
*ctx.network.last_price = value;
Ok(true)
}
The generated certified context includes ctx.cert. The runtime validates the certificate before
the call mutates network state.
Single-Phase Oracle Validation
Single-phase oracle flows validate a proposed call directly. The developer validates application
policy in a validate function. In particular, validate:
- the target
- origin and caller assumptions
- method name
- input payload shape and domain constraints
- any extra off-chain evidence passed to validators
The framework checks protocol metadata and aggregates validator signatures.
The validation method shape is:
#[method::instance(group = oracle::single_phase::price)]
fn validate(
ctx: &mut _,
params: CallParams,
extra: Bytes,
target: OracleTarget,
) -> LyquidResult<bool> {
let proposed = decode_by_fields!(¶ms.input, new_value: U256)
.map(|input| input.new_value);
Ok(proposed == Some(*ctx.instance.local_price.read()) && target.seq_id == lyquor_api::sequence_backend_id()?)
}
The validator receives:
params: the call that would be sequenced if the certificate formsextra: supplementary data from the proposer; this is not signed into the final certified calltarget: target service and sequence backend
The syntax also supports an optional final OracleHeader parameter for validators that need to
inspect protocol header fields.
Two-Phase Oracle Aggregation
Two-phase flows first collect node inputs, aggregate them into a proposed certified call, then
validate that aggregate output. Implement both propose and aggregate under the same
oracle::two_phase::<topic> group.
propose runs on validator nodes and returns each node's local input. Its parameters after ctx
are not fixed by the framework: they are decoded from the init bytes passed to
propose_and_certify (see Submitting Certified Calls), and the same
bytes reappear as ctx.init in aggregate. Declare whatever parameters the aggregation needs:
#[method::instance(group = oracle::two_phase::price)]
fn propose(ctx: &mut _, min_inputs: u16, target: OracleTarget) -> LyquidResult<U256> {
if target.seq_id != lyquor_api::sequence_backend_id()? {
return Err(LyquidError::OracleError("wrong target backend".into()));
}
Ok(*ctx.instance.local_price.read())
}
aggregate decides when enough inputs have arrived and returns the certified network call to
validate:
#[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)?;
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: "value_update".into(),
input: encode_by_fields!(new_value: U256 = median).into(),
target: init.target,
}))
}
Two-phase aggregate is written with ctx: &_, but the macro lowers that binding to
ProposalAggregationContext. That context has:
ctx.init: initial bytes passed by the proposerctx.inputs: signed inputs collected from nodesctx.lyquid_id: current Lyquid ID
Two-phase aggregate rules:
- must be an instance method
- must use
ctx: &_ - must not take extra parameters
- must return
LyquidResult<Option<CertifiedCallParams>> - must not use
export = eth
Use this flow when the certified network call should be derived from multiple local observations.
Submitting Certified Calls
The oracle state variable produces a certified CallParams, which you then submit to the sequencing
backend with submit_certified_call!. There are two entry points, matching the two validation flows.
Single-Phase: certify
certify proposes one concrete CertifiedCallParams and runs the single-phase validate flow over
it. Its arguments after ctx are the proposed call, an extra payload for validators (not signed
into the final certified call), an optional group suffix, and an optional timeout in milliseconds:
#[method::instance(export = eth)]
fn submit_value(ctx: &mut _, value: U256, target: Address, is_evm: bool) -> LyquidResult<bool> {
let target = submit_target(target, is_evm)?;
let oracle = ctx.network.price.clone();
let call = oracle.certify(
&mut ctx,
CertifiedCallParams {
origin: Address::ZERO,
method: "value_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)
}
}
Two-Phase: propose_and_certify
propose_and_certify runs the two-phase propose/aggregate flow instead of certifying a
pre-built call. Its arguments after ctx are the target, the init bytes delivered to every
node's propose handler (and surfaced as ctx.init in aggregate), an optional group suffix, and
an optional timeout. The aggregated CertifiedCallParams is built by aggregate, not by the
caller:
#[method::instance(export = eth)]
fn submit_value_aggregated(ctx: &mut _, target: Address, is_evm: bool) -> LyquidResult<bool> {
let target = submit_target(target, is_evm)?;
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 default submit_certified_call!(call) form relies on the node to sign. The two-argument
submit_certified_call!(call, signed) form passes an explicit signed boolean to the host API.