Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(sequencer): integrate slinky oracle and vote extension logic #1236

Open
wants to merge 85 commits into
base: main
Choose a base branch
from

Conversation

noot
Copy link
Collaborator

@noot noot commented Jul 3, 2024

Summary

integrate skip's slinky oracle service into astria.

at a high level, slinky consists of an oracle sidecar program, which interacts with a validator node to provide price data, and various cosmos-sdk modules.

since astria isn't cosmos, the relevant cosmos modules (x/marketmap and x/oracle) were essentially ported into the slinky module of the sequencer app, which consists of two components, market_map and oracle.

the sequencer app was updated to talk to the sidecar during the extend_vote phase of consensus to gather prices to put into a vote extension.

the vote extension validation logic, proposal logic, and finalization logic were also ported from slinky.

Background

we want oracle data to be available to rollups (and maybe on the sequencer itself too?)

Changes

  • import relevant protos from slinky and create native rust types for them
  • update the sequencer genesis state to contain market_map and oracle values
  • implement the market_map component for the sequencer
    • update the sequencer grpc service to support the market_map grpc service, which is required by the oracle sidecar to retrieve the market map from the chain
  • implement the oracle component for the sequencer and the query service for this component
  • implement extend_vote logic which gets the prices from the sidecar and turns them into a vote extension
  • implement verify_vote_extension logic which performs basic validation on a vote extension during the consensus phase
  • implement prepare_proposal logic which gathers the vote extensions from the previous block, prunes any invalid votes, and performs additional validation to create a valid set of VEs
  • implement process_proposal logic which validates the set of VEs proposed, checking signatures and that the voting power is >2/3 amongst other things
  • implement finalize_block logic which writes the updated prices to state based on the commited vote extensions. skip uses stake-weighted median to calculate the final price, but we don't have stake-weighting yet, so i just took the median.
  • TODO: implement the slinky cosmos Msg types as sequencer actions (follow-up)
  • TODO: update SequencerBlockHeader to contain the extended commit info + a proof for it (also follow-up)
  • TODO: implement the DeltaCurrencyPairStrategy - right now only the DefaultCurrencyPairStrategy is implemented. can also do in follow-up

Testing

TODO: run this on a multi-validator network also

clone slinky: https://github.com/skip-mev/slinky/tree/main

install go 1.22

build and run slinky:

make build
go run scripts/genesis.go --use-coingecko=true --temp-file=markets.json
./build/slinky --market-config-path markets.json

checkout noot/slinky branch of astria

run sequencer app w/ grpc port 9090 and ASTRIA_SEQUENCER_ORACLE_ENABLED=true

rm -rf /tmp/astria_db
rm -rf ~/.cometbft
rm app-genesis-state.json
just run
just run-cometbft

should see a sequencer log like:

astria_sequencer::sequencer: oracle sidecar is reachable

should see a slinky log like:

{"level":"info","ts":"2024-07-02T14:33:46.318-0400","caller":"marketmap/fetcher.go:147","msg":"successfully fetched market map data from module; checking if market map has changed","pid":727051,"process":"oracle","fetcher":"marketmap_api"}

then, when blocks are made, should see logs like the following for each block:

2024-07-05T02:49:21.254163Z DEBUG handle_request:handle_process_proposal: astria_sequencer::service::consensus: proposal processed height=28 time=2024-07-05T02:49:19.143352683Z tx_count=3 proposer=7BE21CDEB6FDCC9299A51F44C6B390EA990E88CD hash=7rmdhOsaW2a0NCZUwSE5yqt2AVR3cOPgGGb4Bb0kpRM= next_validators_hash=F6N7YDQZKfXQld95iV0AmQKNa8DiAxrDnTAcn323QSU=
2024-07-05T02:49:21.310218Z DEBUG handle_request:handle_extend_vote:App::extend_vote: astria_sequencer::app::vote_extension: got prices from oracle sidecar; transforming prices prices_count=118
2024-07-05T02:49:21.323262Z DEBUG handle_request:handle_extend_vote:App::extend_vote: astria_sequencer::app::vote_extension: transformed price for inclusion in vote extension currency_pair="BTC/USD" id=0 price=5683583007
2024-07-05T02:49:21.326070Z DEBUG handle_request:handle_extend_vote:App::extend_vote: astria_sequencer::app::vote_extension: transformed price for inclusion in vote extension currency_pair="ETH/USD" id=1 price=3055069469
2024-07-05T02:49:21.384266Z DEBUG handle_request:finalize_block:App::finalize_block: astria_sequencer::app::vote_extension: applied price from vote extension currency_pair="BTC/USD" price=5683583007 hash=EEB99D84EB1A5B66B4342654C12139CAAB7601547770E3E01866F805BD24A513 height=28 time=2024-07-05T02:49:19.143352683Z proposer=7BE21CDEB6FDCC9299A51F44C6B390EA990E88CD
2024-07-05T02:49:21.384553Z DEBUG handle_request:finalize_block:App::finalize_block: astria_sequencer::app::vote_extension: applied price from vote extension currency_pair="ETH/USD" price=3055069469 hash=EEB99D84EB1A5B66B4342654C12139CAAB7601547770E3E01866F805BD24A513 height=28 time=2024-07-05T02:49:19.143352683Z proposer=7BE21CDEB6FDCC9299A51F44C6B390EA990E88CD

Breaking Changelist

  • the sequencer state is breaking, as the genesis is updated and new keys are stored in state

@github-actions github-actions bot added proto pertaining to the Astria Protobuf spec sequencer pertaining to the astria-sequencer crate labels Jul 3, 2024
@github-actions github-actions bot added the cd label Jul 5, 2024
Copy link
Contributor

@Fraser999 Fraser999 left a comment

Choose a reason for hiding this comment

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

No blockers remaining as far as I'm concerned, but still a few unresolved comments.

Copy link
Member

@SuperFluffy SuperFluffy left a comment

Choose a reason for hiding this comment

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

Many more comments.

Comment on lines 20 to 27
market_map::v1::{
GenesisState as MarketMapGenesisState,
GenesisStateError as MarketMapGenesisStateError,
},
oracle::v1::{
GenesisState as OracleGenesisState,
GenesisStateError as OracleGenesisStateError,
},
Copy link
Member

Choose a reason for hiding this comment

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

Instead of aliasing the types, can you alias the module instead? Like so:

Suggested change
market_map::v1::{
GenesisState as MarketMapGenesisState,
GenesisStateError as MarketMapGenesisStateError,
},
oracle::v1::{
GenesisState as OracleGenesisState,
GenesisStateError as OracleGenesisStateError,
},
market_map::v1 as market_map,
oracle::v1 as oracle,

Then you can refer to the types as market_map::GenesisState and oracle::GenesisState. I prefer to restrict type aliases to cases like hiding type parameters that would be repeated everywhere (as in the case of anyhow::Result<T> = Result<T, anyhow::Error>.

@@ -712,6 +910,25 @@ mod tests {
bridge_sudo_change_fee: Some(24.into()),
ics20_withdrawal_base_fee: Some(24.into()),
}),
slinky: Some(
Copy link
Member

Choose a reason for hiding this comment

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

Can you fill out the genesis data here? This is used in the snapshot test below and it would be great to catch if this changed.

There is a unit test further down (can't comment on it because it's outside the diff range), mismatched_addresses_are_caught, which could also benefit from a check on the market maker address.

SlinkyGenesis {
market_map: MarketMapGenesisState {
market_map: MarketMap {
markets: IndexMap::new(),
Copy link
Member

Choose a reason for hiding this comment

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

For filling in some data here, there is a nice utility crate that provides hashmap! and btreemap! similar to vec! in the standard library: https://docs.rs/maplit/latest/maplit/

And indexmap itself already provides this itself: https://docs.rs/indexmap/latest/indexmap/macro.indexmap.html

"admin": "astria1rsxyjrcm255ds9euthjx6yc3vrjt9sxrm9cfgm"
}
},
"oracle": {}
Copy link
Member

Choose a reason for hiding this comment

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

Commented on the file where the test that led to this snap was defined: would be great to populate this with some extra data.

type Err = CurrencyPairParseError;

fn from_str(s: &str) -> Result<Self, Self::Err> {
let re = regex::Regex::new(r"^([a-zA-Z]+)/([a-zA-Z]+)$").expect("valid regex");
Copy link
Member

Choose a reason for hiding this comment

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

Right now this regex would be recompiled on every parse, which is not very efficient!

Have a look at what we are doing inside composer to avoid that at the bottom of this comment. However, don't use once_cell's Lazy or the new std LazyLock. Both of them block their respective thread which would be bad in an async context.

Instead, use https://doc.rust-lang.org/stable/std/sync/struct.OnceLock.html, which does not:

static CURRENCY_PAIR_REGEX: OnceLock<Regex> = OnceLock::new();
fn currency_pair_regex() -> &Regex {
    CURRENCT_PAIR_REGEX.get_or_init(|| regex::Regex::new(r"^([a-zA-Z]+)/([a-zA-Z]+)$").expect("valid regex"))
}

Also take a look at how we defined the regex in composer, which gives names to the capture groups so its self-documenting. :)

static ROLLUP_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(
r"(?x)
^(?P<rollup_name>[[:alnum:]-]+?)
# lazily match all alphanumeric ascii and dash;
# case insignificant, but we will lowercase later
::
(?P<url>.+)
# treat all following chars as the url without any verification;
# if there are bad chars, the downstream URL parser should
# handle that
$
",
)
.unwrap()
});
let caps = ROLLUP_RE.captures(from).ok_or_else(ParseError::new)?;
// Note that this will panic on invalid indices. However, these
// accesses will always be correct because the regex will only
// match when these capture groups match.
let rollup_name = caps["rollup_name"].to_string().to_lowercase();
let url = caps["url"].to_string();
Ok(Self {
rollup_name,
url,

}

#[instrument(name = "MarketMapComponent::end_block", skip(_state))]
async fn end_block<S: StateWriteExt + 'static>(
Copy link
Member

Choose a reason for hiding this comment

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

S: StateWrite

}

#[instrument(skip_all)]
async fn get_currency_pair_mapping(&self) -> Result<HashMap<u64, CurrencyPair>> {
Copy link
Member

Choose a reason for hiding this comment

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

Instead of returning an object, implement this as a stream returning Items of type to make this more flexible Result<CurrencyPair> (or whatever). See #1453 for how this can be done (this was ultimately merged into #1408)

let mut currency_pairs = HashMap::new();

let mut stream = std::pin::pin!(self.prefix_keys(&prefix));
while let Some(Ok(key)) = stream.next().await {
Copy link
Member

Choose a reason for hiding this comment

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

This is potentially a source of latency: use futures::TryStreamExt::try_collect on a futures::StreamExt::buffered adaptor to do this concurrently.

}

#[instrument(skip_all)]
async fn get_all_currency_pairs(&self) -> Result<Vec<CurrencyPair>> {
Copy link
Member

Choose a reason for hiding this comment

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

As above, implement this as a stream and let the call site decide what to do with this. See #1453 for how to do that.

@@ -15,6 +17,7 @@ message GenesisAppState {
IbcParameters ibc_parameters = 8;
repeated string allowed_fee_assets = 9;
Fees fees = 10;
SlinkyGenesis slinky = 11;
Copy link
Member

Choose a reason for hiding this comment

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

Yeah, I have changed my mind. We should probably call this slinky because there is nothing generic about this. (Or should we use their new *-connect name already?)


pub(crate) async fn get_max_num_currency_pairs<S: StateReadExt>(
state: &S,
is_proposal_phase: bool,
Copy link
Member

Choose a reason for hiding this comment

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

This low level function should not be aware of what happens at the higher level, I think. I looked at the is_proposal_phase function and had to follow it all the way here. IMO move this logic to the call-site.

})
}

pub(crate) async fn verify_vote_extension<S: StateReadExt>(
Copy link
Member

@SuperFluffy SuperFluffy Sep 9, 2024

Choose a reason for hiding this comment

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

Is it intended that if the oracle is disabled, that this function is still called? If the oracle is disabled, NO_ORACLE=true, I would expect that a sequencer node completely abstains from participating in vote extensions.

I think it might be clearer if we separated the config in

  1. participates in vote extensions,
  2. only verifies vote extensions,
  3. ignores vote extensions?

SuperFluffy and others added 2 commits September 9, 2024 12:43
…1470)

Extensions on `cnidarium::StateRead` should implement a stream rather
than allocate and collect into datastructures.
SuperFluffy and others added 4 commits September 19, 2024 15:49
This patch enforces more type strictes on slinky oracle domain types:

1. Nonces, Ids, and Prices are new-type wrappers around their respective
primtives; This is to avoid using `u128` in place of a `Price`, for
example.
2. All oracle types written to state get their own types that themselves
derive the borsh serialization traits.
3. All json is removed from the oracle state read and write extension
trait.
4. A lot of validation code is moved from sequencer to core so that
validation happens closer to the boundary.
5. Functions in the public interface for constructing various validation
errors were removed.
6. Constructors that would allow violating invariants (specifically
invariants around the contents of currency pairs) were removed.

A bug was fixed in the
`oracle::StateReadExt::put_price_for_currency_pair`
  trait method, which was not updating the nonce.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
cd composer pertaining to composer conductor pertaining to the astria-conductor crate proto pertaining to the Astria Protobuf spec sequencer pertaining to the astria-sequencer crate
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants