Skip to main content

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!(&params.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 forms
  • extra: supplementary data from the proposer; this is not signed into the final certified call
  • target: 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 proposer
  • ctx.inputs: signed inputs collected from nodes
  • ctx.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.