feat(standards): add the price oracle standard (PriceOracle, PriceFeed, PriceReaderManager) - #3561
feat(standards): add the price oracle standard (PriceOracle, PriceFeed, PriceReaderManager)#3561onurinanc wants to merge 13 commits into
PriceOracle, PriceFeed, PriceReaderManager)#3561Conversation
|
I would probably simplify this PR: we can start just with the "price oracle" interface. The rest could be added later. |
bobbinth
left a comment
There was a problem hiding this comment.
Looks good! Thank you! I left a few small comments inline.
| # 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. |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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?
| pub const fn new() -> Self { | ||
| Self { rate_provider: None } | ||
| } |
There was a problem hiding this comment.
nit: is this a useful constructor? I'd imagine we'd always want to construct a price oracle with some rate provider.
PhilippGackstatter
left a comment
There was a problem hiding this comment.
Looks good, I left a few comments.
| # 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. |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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
| #! `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)] |
There was a problem hiding this comment.
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.
| #! Inputs: [SOURCE_ASSET_ID, TARGET_ASSET_ID, pad(8)] | ||
| #! Outputs: [num, den, pad(14)] |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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. | ||
| #! |
There was a problem hiding this comment.
I don't remember if we check this in other such procedures, but should we add a check that the account has the procedure?
| /// 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. |
There was a problem hiding this comment.
similar comment as before re stability
| pub struct PriceOracle { | ||
| rate_provider: Option<AccountProcedureRoot>, | ||
| } | ||
|
|
There was a problem hiding this comment.
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<()> { |
There was a problem hiding this comment.
nit: I'm not sure we need this test. We don't have it for other such policy managers, afair.
| /// 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"; |
There was a problem hiding this comment.
related to the other comments, we don't need this, imo
| /// 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(()) | ||
| } |
There was a problem hiding this comment.
This is a unit test, so I would move it to standards, or remove it.
| # 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. |
There was a problem hiding this comment.
| # 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. |
There was a problem hiding this comment.
| # 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
| # 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. |
There was a problem hiding this comment.
| # 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
| 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 |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
How about ACTIVE_FEED or ACTIVE_PRICE_GETTER?
mmagician
left a comment
There was a problem hiding this comment.
LGTM modulo the rename of rate_provider. I left a suggestion, but maybe others have more ideas?
| 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 |
There was a problem hiding this comment.
How about ACTIVE_FEED or ACTIVE_PRICE_GETTER?
|
Related to this comment: #3561 (comment) about renaming
Do you have any other suggestions before making the changes? @bobbinth @PhilippGackstatter |
Closes: #3522