Skip to content

feat(standards): add the price oracle standard (PriceOracle, PriceFeed, PriceReaderManager) - #3561

Open
onurinanc wants to merge 13 commits into
nextfrom
feat-price-oracle
Open

feat(standards): add the price oracle standard (PriceOracle, PriceFeed, PriceReaderManager)#3561
onurinanc wants to merge 13 commits into
nextfrom
feat-price-oracle

Conversation

@onurinanc

Copy link
Copy Markdown
Collaborator

Closes: #3522

@bobbinth

Copy link
Copy Markdown
Contributor

I would probably simplify this PR: we can start just with the "price oracle" interface. The rest could be added later.

@onurinanc
onurinanc marked this pull request as ready for review August 21, 2026 06:05

@bobbinth bobbinth left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good! Thank you! I left a few small comments inline.

Comment on lines +5 to +7
# The rate is returned as a `ConversionRate`, rather than a converted amount.
# Callers apply it with `fee::convert_amount`, which computes
# `ceil(amount * num / den)` at 128-bit intermediate precision, so both paths round the same way.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not for this PR, but we should move convert_amount procedure out of fee module since this procedure is much more general. Don't have a great idea of the right place for it though.

Also (and even less related to this PR), it seems like we have both miden::standards::fee and miden::standards::fees modules. We should probably normalize this somehow.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Somewhat related to the previous comment: I think ConversionRate struct is much more general than the oracle - and probably more general than just accounts either. So, I think we should move it out - maybe to src/utils?

den: Felt,
}

impl ConversionRate {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it also make sense to add something like convert() procedure which could look like so:

impl ConversionRate {
    
    pub fn convert(&self, source: FungibleAsset) -> FungibleAsset {
        ...
    }

}

And make sure this procedure is semantically equivalent to the fee::convert_amount one?

Comment on lines +79 to +81
pub const fn new() -> Self {
Self { rate_provider: None }
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: is this a useful constructor? I'd imagine we'd always want to construct a price oracle with some rate provider.

@PhilippGackstatter PhilippGackstatter left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, I left a few comments.

Comment on lines +9 to +16
# WARNING: the body of `get_conversion_rate` MUST NOT change. Consumers reach it over FPI by its
# MAST root. The body does nothing but read a procedure root from storage and dispatch to it,
# so the rate provider can be replaced without changing the root.
#
# The wrapper is a stable address, NOT a gate. A dispatch target must itself be an account
# procedure to be `dyncall` reachable, which also makes it reachable directly over FPI, so a
# rate provider cannot rely on the wrapper having run first: every guarantee it needs, including
# its transaction expiration delta, has to be enforced in its own body.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would replace this with just something like "get_conversion_rate is a stable MAST root that invokes the rate provider dynamically."

Once miden-standards is stable, all procedure roots we have will be stable, so the "MUST NOT change" part is redundant.

The last part isn't necessarily true. The rate provider account procedure could ensure that it is called from the account context (and I think it is best practice to assert this for all dynamically invoked policy procedures that mutate the account - we don't have any so far) so it is not necessarily reachable via FPI.


#! Returns the rate converting the source asset into the target asset.
#!
#! WARNING: do not edit this procedure since its MAST root is the address consumers resolve against.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: related to the above comment, we don't have such comments on other similar procedures so I don't think it's necessary here

Comment on lines +38 to +45
#! `den = 0` means the active rate provider cannot price the pair, including when its data is too
#! stale to rely on. It is returned rather than raised so a caller valuing many assets does not lose
#! the whole transaction over one. A caller that does not check for it is not left with a wrong
#! answer either: handing such a rate to `fee::convert_amount` aborts on its zero-denominator
#! assertion.
#!
#! Inputs: [SOURCE_ASSET_ID, TARGET_ASSET_ID, pad(8)]
#! Outputs: [num, den, pad(14)]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I would probably make this den = 0 sentinel more explicit by returning a has_conversion_rate boolean flag and saying that num and den are only valid if has_conversion_rate = 1.

Comment on lines +44 to +45
#! Inputs: [SOURCE_ASSET_ID, TARGET_ASSET_ID, pad(8)]
#! Outputs: [num, den, pad(14)]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just by reading, it is hard to understand what num and den are and this also applies to the ConversionRate API in general. I would suggest renaming this to something more descriptive, now or in a separate PR.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll do this in a follow-up PR while moving the convert_amount to the out of fee module.

#!
#! The root must belong to a procedure of this account, since `dyncall` can only reach the account's
#! own procedures.
#!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't remember if we check this in other such procedures, but should we add a check that the account has the procedure?

Comment on lines +53 to +62
/// Install it alongside a rate provider on the same account and an
/// [`Authority`][crate::account::access::Authority], which gates `set_rate_provider`.
/// `get_conversion_rate` dispatches to whichever provider is registered in
/// [`PriceOracle::active_rate_provider_slot`], so the pricing can be replaced without changing the
/// MAST root consumers reach it by.
///
/// The wrapper is a stable address, NOT a gate. A dispatch target must itself be an account
/// procedure to be `dyncall` reachable, which also makes it reachable directly over FPI, so a rate
/// provider cannot rely on the wrapper having run first: every guarantee it needs, including its
/// transaction expiration delta, has to be enforced in its own body.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

similar comment as before re stability

Comment on lines +64 to +67
pub struct PriceOracle {
rate_provider: Option<AccountProcedureRoot>,
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Related to the constructor question from Bobbin, why is this Option and not AccountProcedureRoot?

/// With no rate provider registered the interface aborts rather than dispatching to the empty root,
/// so an unconfigured oracle cannot be mistaken for one reporting nothing.
#[tokio::test]
async fn an_oracle_without_a_rate_provider_aborts() -> anyhow::Result<()> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I'm not sure we need this test. We don't have it for other such policy managers, afair.

Comment on lines +351 to +367
/// The interface's MAST root is the address consumers resolve against, so it must survive changes
/// to the rate provider and to the rest of the standard.
///
/// If this fails, `get_conversion_rate`'s body changed. That is a breaking change for every
/// consumer that already resolved the old root, not a value to update in passing.
#[test]
fn the_interface_root_is_stable() {
assert_eq!(
PriceOracle::get_conversion_rate_root().mast_root().to_hex(),
PINNED_GET_CONVERSION_RATE_ROOT,
"the price oracle interface root changed; see the test documentation"
);
}

/// The MAST root of `price_oracle::get_conversion_rate`.
const PINNED_GET_CONVERSION_RATE_ROOT: &str =
"0xed1c07b8a0f3a24addec5ec6651ab0ec5b1e9615d70f00cd1c3f227cbd1a90d9";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

related to the other comments, we don't need this, imo

Comment on lines +336 to +349
/// The rate provider supplied at genesis is the one the interface dispatches to.
#[test]
fn the_component_seeds_the_rate_provider() -> anyhow::Result<()> {
let code = rate_providers_code()?;
let root = rate_provider_root(&code, "fixed_rate_1500_over_3")?;
let oracle = oracle_account(&code, Some(root))?;

assert_eq!(
oracle.storage().get_item(PriceOracle::active_rate_provider_slot())?,
*root.mast_root()
);

Ok(())
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a unit test, so I would move it to standards, or remove it.

Comment on lines +3 to +5
# Exposes the user-facing `get_conversion_rate`, whose MAST root is the address consumers resolve
# against and must therefore stay stable, plus the authority-gated setter selecting the rate
# provider it dispatches to.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# Exposes the user-facing `get_conversion_rate`, whose MAST root is the address consumers resolve
# against and must therefore stay stable, plus the authority-gated setter selecting the rate
# provider it dispatches to.
# Exposes the user-facing `get_conversion_rate`, plus the authority-gated setter selecting the rate
# provider it dispatches to.

#
# The user-facing price oracle interface: a single procedure returning the rate between two assets.
#
# The rate is returned as a `ConversionRate`, rather than a converted amount.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# The rate is returned as a `ConversionRate`, rather than a converted amount.
# The rate is returned as a `ConversionRate`.

let's only describe current behavior (not absent behavior) in the docs

Comment on lines +6 to +7
# Callers apply it with `fee::convert_amount`, which computes
# `ceil(amount * num / den)` at 128-bit intermediate precision, so both paths round the same way.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
# Callers apply it with `fee::convert_amount`, which computes
# `ceil(amount * num / den)` at 128-bit intermediate precision, so both paths round the same way.
# Callers apply it with `fee::convert_amount`.

I'm not sure these details are relevant for these docs

Comment on lines +94 to +98
pub proc set_rate_provider(rate_provider_proc_root: AccountProcedureRoot)
exec.authority::assert_authorized
# => [RATE_PROVIDER_PROC_ROOT, pad(12)]

push.ACTIVE_RATE_PROVIDER_PROC_ROOT_SLOT[0..2] exec.native_account::set_item

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would consider renaming "rate provider" to something else.

When I first read the term "rate provider", I thought that it refers to the entity setting the rates, i.e. the entity feeding the prices to the oracle.

Instead, it refers to the custom/dynamic procedure which performs the price feed read.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree, but I'm not sure what should be a better naming. Maybe, ACTIVE_IMPLEMENTATION_PROC_ROOT_SLOT and set_implementation but I'm not sure if the "implementation" here is too generic.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about ACTIVE_FEED or ACTIVE_PRICE_GETTER?

@mmagician mmagician left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM modulo the rename of rate_provider. I left a suggestion, but maybe others have more ideas?

Comment on lines +94 to +98
pub proc set_rate_provider(rate_provider_proc_root: AccountProcedureRoot)
exec.authority::assert_authorized
# => [RATE_PROVIDER_PROC_ROOT, pad(12)]

push.ACTIVE_RATE_PROVIDER_PROC_ROOT_SLOT[0..2] exec.native_account::set_item

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about ACTIVE_FEED or ACTIVE_PRICE_GETTER?

@onurinanc

Copy link
Copy Markdown
Collaborator Author

Related to this comment: #3561 (comment) about renaming rate_provider:

  • ACTIVE_RATE_PROVIDER -> ACTIVE_PRICE_GETTER seems good to me.

Do you have any other suggestions before making the changes? @bobbinth @PhilippGackstatter

@mmagician
mmagician requested a review from Fumuran August 27, 2026 12:37

@Fumuran Fumuran left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great work, thank you!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add Price Oracle Standard

5 participants