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(client): l1 gas price #219

Merged
merged 51 commits into from
Aug 21, 2024
Merged

Conversation

Mohiiit
Copy link
Contributor

@Mohiiit Mohiiit commented Aug 7, 2024

Pull Request type

  • Feature

What is the current behavior?

Resolves: #205

What is the new behavior?

  • Added a worker within the eth crate in order to fetch the gas prices from the l1
  • Added tests regarding the same

Does this introduce a breaking change?

No

Other Information

In the current scope of the PR, we are not aiming to add oracle to fetch the STRK <-> ETH price for the strk conversion of the fees.

Current Progress

  • Creating L1 gas price worker
  • unit test for the worker
  • supplying gas price to the block production service

mohiiit added 30 commits August 7, 2024 10:35
@Mohiiit Mohiiit marked this pull request as ready for review August 8, 2024 12:57
infinite_loop: bool,
) -> anyhow::Result<()> {
let poll_time = eth_client.gas_price_poll_ms.unwrap_or(DEFAULT_GAS_PRICE_POLL_MS);
update_last_update_timestamp().await;
Copy link
Contributor

Choose a reason for hiding this comment

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

is this incorrect? i can see the timestamp is updated inside update_gas_price and it should only be there from what I understand

Copy link
Contributor Author

Choose a reason for hiding this comment

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

this is for the first time, time stamp is defined in the lazy static, and when the gas worker is called, it will update it for the first time and then for the next time it will get updated in the update_gas_price

Copy link
Contributor Author

Choose a reason for hiding this comment

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

this is removed because we are calling the update_gas_price while creating the sync service

);
}

if !infinite_loop {
Copy link
Contributor

Choose a reason for hiding this comment

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

the purpose of the infinite_loop variable was to call this function once at the start of the node without looping to set the initial gas price, I think we should do that as well here

Copy link
Contributor Author

Choose a reason for hiding this comment

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

resolved

Copy link
Member

Choose a reason for hiding this comment

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

i think this function would look better if it was split into two:
the loop body is a function, which corresponds to when the current infinite_loop false when called directly, and a separate function for the infinite task which calls the first function


let avg_blob_base_fee = blob_fee_history_one_hour.iter().sum::<u128>() / blob_fee_history_one_hour.len() as u128;

let eth_gas_price = fee_history.base_fee_per_blob_gas.last().context("Setting l1 last confirmed block number")?;
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
let eth_gas_price = fee_history.base_fee_per_blob_gas.last().context("Setting l1 last confirmed block number")?;
let eth_gas_price = fee_history.base_fee_gas.last().context("Getting eth gas price")?;

Copy link
Contributor

Choose a reason for hiding this comment

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

anyway we can ensure this in test cases? I think anvil allows you to set a custom gas price when you start it

Copy link
Contributor Author

Choose a reason for hiding this comment

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

yeah that's true, I think we can achieve this using mocks right?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

resolved

eth_client.l1_block_metrics.l1_block_number.set(latest_block_number as f64);
eth_client.l1_block_metrics.l1_gas_price_wei.set(eth_gas_price as f64);

// We're ignoring l1_gas_price_strk
Copy link
Contributor

Choose a reason for hiding this comment

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

can we import the strk code as well? i remember pragma did a PR. or do we want to do it in another issue

Copy link
Contributor Author

Choose a reason for hiding this comment

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

we would need an oracle for that right? I will check for the PR, it can be another PR with the scope of adding oracle

let l1_data_provider: Arc<dyn L1DataProvider> = Arc::new(GasPriceProvider::new());

// Run the worker for a short time
let worker_handle = tokio::spawn(gas_price_worker(eth_client, l1_data_provider.clone(), false));
Copy link
Contributor

Choose a reason for hiding this comment

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

  • if infinite_loop is false, do we need to spawn tokio? we can just await here right?
  • we should also write a case for infinite loop true
  • how does the current code work without a delay?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

  • yeah I think that should work, using the await only
  • will do, we have that when we are testing for the fail case but will create for this as well
  • there is a delay, it's in the eth_client

let now = SystemTime::now().duration_since(UNIX_EPOCH).expect("Time went backwards").as_secs() as u128;

let last_update_timestamp = get_last_update_timestamp().await;
assert!(last_update_timestamp > now - 60, "Last update timestamp should be within the last minute");
Copy link
Contributor

Choose a reason for hiding this comment

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

what is the timestamp when the test starts?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

it get's updated in the gas worker

#[async_trait::async_trait]
impl L1DataProvider for GasPriceProvider {
async fn get_gas_prices(&self) -> GasPrices {
(*self.gas_prices.lock().await).clone()
Copy link
Contributor

Choose a reason for hiding this comment

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

let's mention that it's 4 u128 so it's ok to clone (unless we think it isn't?)


/// The L1 rpc endpoint url for state verification.
#[clap(long, value_parser = parse_url, value_name = "ETHEREUM RPC URL")]
pub l1_endpoint: Option<Url>,
Copy link
Contributor

Choose a reason for hiding this comment

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

you can enforce in clippy that sync_l1_disabled false requires l1_endpoint afaik

Copy link
Contributor Author

Choose a reason for hiding this comment

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

resolved

Comment on lines 33 to 43
let l1_endpoint = if !config.sync_l1_disabled {
if let Some(l1_rpc_url) = &config.l1_endpoint {
Some(l1_rpc_url.clone())
} else {
return Err(anyhow::anyhow!(
"❗ No L1 endpoint provided. You must provide one in order to verify the synced state."
));
}
} else {
None
};
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
let l1_endpoint = if !config.sync_l1_disabled {
if let Some(l1_rpc_url) = &config.l1_endpoint {
Some(l1_rpc_url.clone())
} else {
return Err(anyhow::anyhow!(
"❗ No L1 endpoint provided. You must provide one in order to verify the synced state."
));
}
} else {
None
};
let l1_endpoint = config.sync_l1_disabled
.not()
.then_some(config.l1_endpoint.clone()
.context("❗ No L1 endpoint provided. You must provide one in order to verify the synced state.")?
);

Copy link
Contributor Author

Choose a reason for hiding this comment

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

resolved

};

let core_address = Address::from_slice(l1_core_address.as_bytes());
let eth_client = EthereumClient::new(l1_endpoint.unwrap(), core_address, metrics_handle)
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
let eth_client = EthereumClient::new(l1_endpoint.unwrap(), core_address, metrics_handle)
let eth_client = EthereumClient::new(l1_endpoint?, core_address, metrics_handle)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

resolved

Copy link
Member

@cchudant cchudant left a comment

Choose a reason for hiding this comment

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

I am not sure if this pull request:

  • fetches gas prices everytime a transaction is added to the mempool / a block is produced -- the L1DataProvider functions are async
  • or is an infinite task running in the background, watching for l1 gas prices and updating a mutex that is shared with the mempool through the L1DataProvider abstraction -- the L1DataProvider functions are sync

which of the two does this pr implement?

I don't like the first one as:

  • the mempool needs L1 prices for mempool validation
  • the block production task needs L1 prices to produce blocks
  • i don't like the idea of l1 gas prices request being initiated by a add_transaction rpc
  • i don't like having the block production be slowed down by l1, as it's potentially time critical

The second approach has a major drawback that i'm realizing now:

  • we shouldnt accept any mempool transaction before the first gas prices arrive.
  • we cannot produce any block before the first gas prices arrive otherwise we could have a huge problem

I think the easiest way to solve it is:

  • have L1DataProvider look like this
pub struct NewBlockL1Info {
  pub data_availability_mode: L1DataAvailabilityMode, // blob or calldata
  pub gas_prices: GasPrices,
}

trait L1DataProvider {
  /// This returns immediately when GasPrices are available,
  /// or will wait until the L1 watcher has started up and got the first prices.
  async fn get_gas_prices(&self) -> NewBlockL1Info;
}

// tokio watch channel
struct ProvideL1Data(tokio::sync::watch::Receiver<Option<NewBlockL1Info>>);
impl L1DataProvider for ProvideL1Data {
  async fn get_gas_prices(&self) -> NewBlockL1Info {
    self.0.wait_for(Option::is_some).await.expect("recv error").clone()
  }
}

async fn l1_gas_prices_task(set_gas_prices: tokio::sync::watch::Sender<Option<NewBlockL1Info>>) -> anyhow::Result<()> {
  loop {
    get prices
    if set_gas_prices.send(Some(gas_prices)).is_err() {
      // channel is closed, meaning no one is listening to gas prices
      return Ok(());
    }
    interval sleep
  }
}

use std::time::{SystemTime, UNIX_EPOCH};

lazy_static::lazy_static! {
static ref LAST_UPDATE_TIMESTAMP: Arc<Mutex<u128>> = Arc::new(Mutex::new(0));
Copy link
Member

Choose a reason for hiding this comment

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

can this be inside the service instead of global state?

Copy link
Member

Choose a reason for hiding this comment

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

also use a SystemTime directly instead of an untyped integer unix epoch?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

resolved

anyhow::anyhow!("EthereumClient is required to start the l1 sync service but not provided.")
})?;
// running at-least once before the block production service
let _ = futures::executor::block_on(dc_eth::l1_gas_price::gas_price_worker(
Copy link
Member

Choose a reason for hiding this comment

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

what is that block_on? this looks suspicious

Copy link
Contributor Author

Choose a reason for hiding this comment

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

this is to fetch the gas price before returning the sync service, it make sure we have gas price


/// Disable the gas price sync service. The sync service is responsible to fetch the fee history from the ethereum.
#[clap(long, alias = "no-gas-price-sync")]
pub gas_price_sync_disabled: bool,
Copy link
Member

Choose a reason for hiding this comment

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

i like it when everything is configurable thank you :)

};

let core_address = Address::from_slice(config.network.l1_core_address().as_bytes());
let eth_client = EthereumClient::new(l1_endpoint.unwrap(), core_address, metrics_handle)
Copy link
Member

Choose a reason for hiding this comment

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

when making #221 i found out that you broke sync_l1_disabled on main in the last pr due to this unwrap - i fixed it there for now but is it fixed in this pr?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I checked that, and yeah it's resolved in this PR

gas_price_sync_disabled: bool,
gas_price_poll_ms: u64,
) -> anyhow::Result<()> {
let state_update_fut = async { state_update_worker(backend, eth_client, chain_id).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 func can be simplified to

tokio::try_join!(
  state_update_worker(backend,eth_client, chain_id),
  async {
    if !gas_price_sync_disabled {
      gas_price_worker(eth_client, l1_data_provider, true, gas_price_poll_ms).await?;
    }
    Ok(())
  },
)?;

pub trait L1DataProvider: Send + Sync {
/// Get L1 data gas prices. This needs an oracle for STRK prices.
fn get_gas_prices(&self) -> GasPrices;
async fn get_gas_prices(&self) -> GasPrices;
async fn set_gas_prices(&self, new_prices: GasPrices);
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 remove those setters from the trait? this is an internal impl detail that other crate shouldn't have access

Copy link
Contributor Author

Choose a reason for hiding this comment

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

resolved

@@ -52,7 +52,7 @@ impl Mempool {
Mempool { backend, l1_data_provider, inner: Default::default() }
}

pub fn accept_account_tx(
pub async fn accept_account_tx(
Copy link
Member

Choose a reason for hiding this comment

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

mmh i don't like that being async i need to think about this

Copy link
Contributor Author

Choose a reason for hiding this comment

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

resolved

.as_millis();

if current_timestamp - last_update_timestamp > 10 * gas_price_poll_ms as u128 {
panic!(
Copy link
Member

Choose a reason for hiding this comment

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

why panic here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

in case we are not able to fetch the latest gas prices, it was present in the madara code as well. I believe it exist to make sure we don't have gas fees deviation. but we can have a flag for this as well

);
}

if !infinite_loop {
Copy link
Member

Choose a reason for hiding this comment

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

i think this function would look better if it was split into two:
the loop body is a function, which corresponds to when the current infinite_loop false when called directly, and a separate function for the infinite task which calls the first function

return Ok(());
}

sleep(Duration::from_millis(gas_price_poll_ms)).await;
Copy link
Member

Choose a reason for hiding this comment

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

use

  let mut interval = tokio::time::interval(pending_block_poll_interval);
  interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
  loop {
    // work
    interval.tick().await;
  }

instead of sleep for more accurate intervals and tick skipping behavior in case the node is overloaded

Copy link
Member

Choose a reason for hiding this comment

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

actually, infinite loops should look like this:

    let mut interval = tokio::time::interval(pending_block_poll_interval);
    interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
    while wait_or_graceful_shutdown(interval.tick()).await.is_some() {

to handle graceful shutdown of the node

Copy link
Contributor Author

Choose a reason for hiding this comment

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

resolved

@cchudant cchudant mentioned this pull request Aug 9, 2024
1 task
@Mohiiit
Copy link
Contributor Author

Mohiiit commented Aug 9, 2024

I am not sure if this pull request:

  • fetches gas prices everytime a transaction is added to the mempool / a block is produced -- the L1DataProvider functions are async
  • or is an infinite task running in the background, watching for l1 gas prices and updating a mutex that is shared with the mempool through the L1DataProvider abstraction -- the L1DataProvider functions are sync

which of the two does this pr implement?

it implements the second one, the functions are async because reading from mutex is async and hence all the functions inside the block production service which requires the gas fees needs to be async

The second approach has a major drawback that i'm realizing now:

  • we shouldnt accept any mempool transaction before the first gas prices arrive.

that's why we are getting the gas price in the L1SyncService::new

@Mohiiit Mohiiit requested a review from cchudant August 10, 2024 06:19
Copy link
Member

@cchudant cchudant left a comment

Choose a reason for hiding this comment

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

looks good to me :)

@antiyro antiyro merged commit 28fe200 into madara-alliance:main Aug 21, 2024
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
Status: Done
Development

Successfully merging this pull request may close these issues.

feat(l1): Forward L1 gas price data to the block header
4 participants