Parcourir la source

[hermes] add /v2/price_feeds endpoint (#1277)

* initial stab

* fix comments

* add filter feature

* fix deprecated warnings

* use cache

* Update price_feeds API query

* Update PriceFeedsQueryParams struct in price_feeds.rs

* fix merge conflict

* fix default value

* add tracing info

* fix comment

* address comments

* change var name

* refactor

* refactor

* refactor

* refactor

* undo changes in cache.rs

* undo changes in aggregate.rs

* address comments

* address comments

* address comments and improve fetching data speed

* address comments

* address comments

* bump

* change chunk size

* change function name

* address comment

* address comments

* address comments

* address comments

* Remove debug print statement

* address comments and add to openapi
Daniel Chew il y a 1 an
Parent
commit
9fd9e170b2

Fichier diff supprimé car celui-ci est trop grand
+ 422 - 147
hermes/Cargo.lock


+ 5 - 4
hermes/Cargo.toml

@@ -1,6 +1,6 @@
 [package]
 name        = "hermes"
-version     = "0.5.2"
+version     = "0.5.3"
 description = "Hermes is an agent that provides Verified Prices from the Pythnet Pyth Oracle."
 edition     = "2021"
 
@@ -30,6 +30,7 @@ nonzero_ext        = { version = "0.3.0" }
 prometheus-client  = { version = "0.21.2" }
 prost              = { version = "0.12.1" }
 pyth-sdk           = { version = "0.8.0" }
+pyth-sdk-solana    = { version = "0.9.0" }
 pythnet-sdk        = { path = "../pythnet/pythnet_sdk/", version = "2.0.0", features = ["strum"] }
 rand               = { version = "0.8.5" }
 reqwest            = { version = "0.11.14", features = ["blocking", "json"] }
@@ -50,9 +51,9 @@ utoipa-swagger-ui  = { version = "3.1.4", features = ["axum"] }
 wormhole-sdk       = { git     = "https://github.com/wormhole-foundation/wormhole", tag = "v2.17.1" }
 
 # We are bound to this Solana version in order to match pyth-oracle.
-solana-client          = { version = "=1.13.3" }
-solana-sdk             = { version = "=1.13.3" }
-solana-account-decoder = { version = "=1.13.3" }
+solana-client          = { version = "=1.16.19" }
+solana-sdk             = { version = "=1.16.19" }
+solana-account-decoder = { version = "=1.16.19" }
 
 
 [build-dependencies]

+ 4 - 1
hermes/src/aggregate.rs

@@ -431,6 +431,7 @@ where
 
 pub async fn is_ready(state: &State) -> bool {
     let metadata = state.aggregate_state.read().await;
+    let price_feeds_metadata = state.price_feeds_metadata.read().await;
 
     let has_completed_recently = match metadata.latest_completed_update_at.as_ref() {
         Some(latest_completed_update_time) => {
@@ -449,7 +450,9 @@ pub async fn is_ready(state: &State) -> bool {
         _ => false,
     };
 
-    has_completed_recently && is_not_behind
+    let is_metadata_loaded = !price_feeds_metadata.is_empty();
+
+    has_completed_recently && is_not_behind && is_metadata_loaded
 }
 
 #[cfg(test)]

+ 4 - 0
hermes/src/api.rs

@@ -123,6 +123,7 @@ pub async fn run(opts: RunOptions, state: ApiState) -> Result<()> {
             rest::price_feed_ids,
             rest::latest_price_updates,
             rest::timestamp_price_updates,
+            rest::price_feeds_metadata,
         ),
         components(
             schemas(
@@ -139,6 +140,8 @@ pub async fn run(opts: RunOptions, state: ApiState) -> Result<()> {
                 types::BinaryPriceUpdate,
                 types::ParsedPriceUpdate,
                 types::RpcPriceFeedMetadataV2,
+                types::PriceFeedMetadata,
+                types::AssetType
             )
         ),
         tags(
@@ -164,6 +167,7 @@ pub async fn run(opts: RunOptions, state: ApiState) -> Result<()> {
             "/v2/updates/price/:publish_time",
             get(rest::timestamp_price_updates),
         )
+        .route("/v2/price_feeds", get(rest::price_feeds_metadata))
         .route("/live", get(rest::live))
         .route("/ready", get(rest::ready))
         .route("/ws", get(ws::ws_route_handler))

+ 5 - 0
hermes/src/api/rest.rs

@@ -33,6 +33,7 @@ pub use {
     ready::*,
     v2::{
         latest_price_updates::*,
+        price_feeds_metadata::*,
         timestamp_price_updates::*,
     },
 };
@@ -43,6 +44,7 @@ pub enum RestError {
     CcipUpdateDataNotFound,
     InvalidCCIPInput,
     PriceIdsNotFound { missing_ids: Vec<PriceIdentifier> },
+    RpcConnectionError { message: String },
 }
 
 impl IntoResponse for RestError {
@@ -80,6 +82,9 @@ impl IntoResponse for RestError {
                 )
                     .into_response()
             }
+            RestError::RpcConnectionError { message } => {
+                (StatusCode::INTERNAL_SERVER_ERROR, message).into_response()
+            }
         }
     }
 }

+ 1 - 0
hermes/src/api/rest/index.rs

@@ -18,5 +18,6 @@ pub async fn index() -> impl IntoResponse {
         "/api/get_vaa_ccip?data=<0x<price_feed_id_32_bytes>+<publish_time_unix_timestamp_be_8_bytes>>",
         "/v2/updates/price/latest?ids[]=<price_feed_id>&ids[]=<price_feed_id_2>&..(&encoding=hex|base64)(&parsed=false)",
         "/v2/updates/price/<timestamp>?ids[]=<price_feed_id>&ids[]=<price_feed_id_2>&..(&encoding=hex|base64)(&parsed=false)",
+        "/v2/price_feeds?(query=btc)(&asset_type=crypto|equity|fx|metal|rates)",
     ])
 }

+ 1 - 0
hermes/src/api/rest/v2/mod.rs

@@ -1,2 +1,3 @@
 pub mod latest_price_updates;
+pub mod price_feeds_metadata;
 pub mod timestamp_price_updates;

+ 64 - 0
hermes/src/api/rest/v2/price_feeds_metadata.rs

@@ -0,0 +1,64 @@
+use {
+    crate::{
+        api::{
+            rest::RestError,
+            types::{
+                AssetType,
+                PriceFeedMetadata,
+            },
+        },
+        price_feeds_metadata::get_price_feeds_metadata,
+    },
+    anyhow::Result,
+    axum::{
+        extract::State,
+        Json,
+    },
+    serde::Deserialize,
+    serde_qs::axum::QsQuery,
+    utoipa::IntoParams,
+};
+
+
+#[derive(Debug, Deserialize, IntoParams)]
+#[into_params(parameter_in=Query)]
+pub struct PriceFeedsMetadataQueryParams {
+    /// Optional query parameter. If provided, the results will be filtered to all price feeds whose symbol contains the query string. Query string is case insensitive.
+    #[param(example = "bitcoin")]
+    query: Option<String>,
+
+    /// Optional query parameter. If provided, the results will be filtered by asset type. Possible values are crypto, equity, fx, metal, rates. Filter string is case insensitive.
+    #[param(example = "crypto")]
+    asset_type: Option<AssetType>,
+}
+
+/// Get the set of price feeds.
+///
+/// This endpoint fetches all price feeds from the Pyth network. It can be filtered by asset type
+/// and query string.
+#[utoipa::path(
+    get,
+    path = "/v2/price_feeds",
+    responses(
+        (status = 200, description = "Price feeds metadata retrieved successfully", body = Vec<RpcPriceIdentifier>)
+    ),
+    params(
+        PriceFeedsMetadataQueryParams
+    )
+)]
+pub async fn price_feeds_metadata(
+    State(state): State<crate::api::ApiState>,
+    QsQuery(params): QsQuery<PriceFeedsMetadataQueryParams>,
+) -> Result<Json<Vec<PriceFeedMetadata>>, RestError> {
+    let price_feeds_metadata =
+        get_price_feeds_metadata(&*state.state, params.query, params.asset_type)
+            .await
+            .map_err(|e| {
+                tracing::warn!("RPC connection error: {}", e);
+                RestError::RpcConnectionError {
+                    message: format!("RPC connection error: {}", e),
+                }
+            })?;
+
+    Ok(Json(price_feeds_metadata))
+}

+ 43 - 11
hermes/src/api/types.rs

@@ -28,6 +28,14 @@ use {
         Deserialize,
         Serialize,
     },
+    std::{
+        collections::BTreeMap,
+        fmt::{
+            Display,
+            Formatter,
+            Result as FmtResult,
+        },
+    },
     utoipa::ToSchema,
     wormhole_sdk::Chain,
 };
@@ -52,7 +60,7 @@ impl From<PriceIdInput> for PriceIdentifier {
 
 type Base64String = String;
 
-#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, ToSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
 pub struct RpcPriceFeedMetadata {
     #[schema(value_type = Option<u64>, example=85480034)]
     pub slot:                       Option<Slot>,
@@ -64,7 +72,7 @@ pub struct RpcPriceFeedMetadata {
     pub prev_publish_time:          Option<UnixTimestamp>,
 }
 
-#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, ToSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
 pub struct RpcPriceFeedMetadataV2 {
     #[schema(value_type = Option<u64>, example=85480034)]
     pub slot:                 Option<Slot>,
@@ -74,7 +82,7 @@ pub struct RpcPriceFeedMetadataV2 {
     pub prev_publish_time:    Option<UnixTimestamp>,
 }
 
-#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, ToSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
 pub struct RpcPriceFeed {
     pub id:        RpcPriceIdentifier,
     pub price:     RpcPrice,
@@ -142,8 +150,8 @@ impl RpcPriceFeed {
     Eq,
     BorshSerialize,
     BorshDeserialize,
-    serde::Serialize,
-    serde::Deserialize,
+    Serialize,
+    Deserialize,
     ToSchema,
 )]
 pub struct RpcPrice {
@@ -178,8 +186,8 @@ pub struct RpcPrice {
     Hash,
     BorshSerialize,
     BorshDeserialize,
-    serde::Serialize,
-    serde::Deserialize,
+    Serialize,
+    Deserialize,
     ToSchema,
 )]
 #[repr(C)]
@@ -204,7 +212,7 @@ impl From<PriceIdentifier> for RpcPriceIdentifier {
     }
 }
 
-#[derive(Clone, Copy, Debug, Default, serde::Deserialize, serde::Serialize, ToSchema)]
+#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, ToSchema)]
 pub enum EncodingType {
     #[default]
     #[serde(rename = "hex")]
@@ -222,13 +230,13 @@ impl EncodingType {
     }
 }
 
-#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, ToSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
 pub struct BinaryPriceUpdate {
     pub encoding: EncodingType,
     pub data:     Vec<String>,
 }
 
-#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, ToSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
 pub struct ParsedPriceUpdate {
     pub id:        RpcPriceIdentifier,
     pub price:     RpcPrice,
@@ -263,7 +271,7 @@ impl From<PriceFeedUpdate> for ParsedPriceUpdate {
     }
 }
 
-#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, ToSchema)]
+#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
 pub struct PriceUpdate {
     pub binary: BinaryPriceUpdate,
     #[serde(skip_serializing_if = "Option::is_none")]
@@ -316,3 +324,27 @@ impl TryFrom<PriceUpdate> for PriceFeedsWithUpdateData {
         })
     }
 }
+
+#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
+pub struct PriceFeedMetadata {
+    pub id:         PriceIdentifier,
+    // BTreeMap is used to automatically sort the keys to ensure consistent ordering of attributes in the JSON response.
+    // This enhances user experience by providing a predictable structure, avoiding confusion from varying orders in different responses.
+    pub attributes: BTreeMap<String, String>,
+}
+
+#[derive(Debug, Serialize, Deserialize, PartialEq, ToSchema)]
+#[serde(rename_all = "lowercase")]
+pub enum AssetType {
+    Crypto,
+    FX,
+    Equity,
+    Metals,
+    Rates,
+}
+
+impl Display for AssetType {
+    fn fmt(&self, f: &mut Formatter) -> FmtResult {
+        write!(f, "{:?}", self)
+    }
+}

+ 12 - 1
hermes/src/config/pythnet.rs

@@ -1,4 +1,9 @@
-use clap::Args;
+use {
+    clap::Args,
+    solana_sdk::pubkey::Pubkey,
+};
+
+const DEFAULT_PYTHNET_MAPPING_ADDR: &str = "AHtgzX45WTKfkPG53L6WYhGEXwQkN1BVknET3sVsLL8J";
 
 #[derive(Args, Clone, Debug)]
 #[command(next_help_heading = "Pythnet Options")]
@@ -13,4 +18,10 @@ pub struct Options {
     #[arg(long = "pythnet-http-addr")]
     #[arg(env = "PYTHNET_HTTP_ADDR")]
     pub http_addr: String,
+
+    /// Pyth mapping account address.
+    #[arg(long = "mapping-address")]
+    #[arg(default_value = DEFAULT_PYTHNET_MAPPING_ADDR)]
+    #[arg(env = "MAPPING_ADDRESS")]
+    pub mapping_addr: Pubkey,
 }

+ 1 - 0
hermes/src/main.rs

@@ -21,6 +21,7 @@ mod api;
 mod config;
 mod metrics_server;
 mod network;
+mod price_feeds_metadata;
 mod serde;
 mod state;
 

+ 133 - 8
hermes/src/network/pythnet.rs

@@ -1,6 +1,6 @@
 //! This module connects to the Pythnet RPC server and listens for accumulator
 //! updates. It then sends the updates to the store module for processing and
-//! storage.
+//! storage. It also periodically fetches and stores the latest price feeds metadata.
 
 use {
     crate::{
@@ -8,6 +8,7 @@ use {
             AccumulatorMessages,
             Update,
         },
+        api::types::PriceFeedMetadata,
         config::RunOptions,
         network::wormhole::{
             update_guardian_set,
@@ -15,6 +16,10 @@ use {
             GuardianSet,
             GuardianSetData,
         },
+        price_feeds_metadata::{
+            store_price_feeds_metadata,
+            DEFAULT_PRICE_FEEDS_CACHE_UPDATE_INTERVAL,
+        },
         state::State,
     },
     anyhow::{
@@ -23,6 +28,11 @@ use {
     },
     borsh::BorshDeserialize,
     futures::stream::StreamExt,
+    pyth_sdk::PriceIdentifier,
+    pyth_sdk_solana::state::{
+        load_mapping_account,
+        load_product_account,
+    },
     solana_account_decoder::UiAccountEncoding,
     solana_client::{
         nonblocking::{
@@ -41,11 +51,13 @@ use {
     },
     solana_sdk::{
         account::Account,
+        bs58,
         commitment_config::CommitmentConfig,
         pubkey::Pubkey,
         system_program,
     },
     std::{
+        collections::BTreeMap,
         sync::{
             atomic::Ordering,
             Arc,
@@ -136,11 +148,10 @@ pub async fn run(store: Arc<State>, pythnet_ws_endpoint: String) -> Result<()> {
             encoding: Some(UiAccountEncoding::Base64Zstd),
             ..Default::default()
         },
-        filters:        Some(vec![RpcFilterType::Memcmp(Memcmp {
-            offset:   0,
-            bytes:    MemcmpEncodedBytes::Bytes(b"PAS1".to_vec()),
-            encoding: None,
-        })]),
+        filters:        Some(vec![RpcFilterType::Memcmp(Memcmp::new(
+            0,                                           // offset
+            MemcmpEncodedBytes::Bytes(b"PAS1".to_vec()), // bytes
+        ))]),
         with_context:   Some(true),
     };
 
@@ -257,6 +268,9 @@ async fn fetch_existing_guardian_sets(
 pub async fn spawn(opts: RunOptions, state: Arc<State>) -> Result<()> {
     tracing::info!(endpoint = opts.pythnet.ws_addr, "Started Pythnet Listener.");
 
+    // Create RpcClient instance here
+    let rpc_client = RpcClient::new(opts.pythnet.http_addr.clone());
+
     fetch_existing_guardian_sets(
         state.clone(),
         opts.pythnet.http_addr.clone(),
@@ -284,7 +298,7 @@ pub async fn spawn(opts: RunOptions, state: Arc<State>) -> Result<()> {
         })
     };
 
-    let task_guadian_watcher = {
+    let task_guardian_watcher = {
         let store = state.clone();
         let pythnet_http_endpoint = opts.pythnet.http_addr.clone();
         tokio::spawn(async move {
@@ -317,6 +331,117 @@ pub async fn spawn(opts: RunOptions, state: Arc<State>) -> Result<()> {
         })
     };
 
-    let _ = tokio::join!(task_listener, task_guadian_watcher);
+
+    let task_price_feeds_metadata_updater = {
+        let price_feeds_state = state.clone();
+        tokio::spawn(async move {
+            while !crate::SHOULD_EXIT.load(Ordering::Acquire) {
+                if let Err(e) = fetch_and_store_price_feeds_metadata(
+                    price_feeds_state.as_ref(),
+                    &opts.pythnet.mapping_addr,
+                    &rpc_client,
+                )
+                .await
+                {
+                    tracing::error!("Error in fetching and storing price feeds metadata: {}", e);
+                }
+                // This loop with a sleep interval of 1 second allows the task to check for an exit signal at a
+                // fine-grained interval. Instead of sleeping directly for the entire `price_feeds_update_interval`,
+                // which could delay the response to an exit signal, this approach ensures the task can exit promptly
+                // if `crate::SHOULD_EXIT` is set, enhancing the responsiveness of the service to shutdown requests.
+                for _ in 0..DEFAULT_PRICE_FEEDS_CACHE_UPDATE_INTERVAL {
+                    if crate::SHOULD_EXIT.load(Ordering::Acquire) {
+                        break;
+                    }
+                    tokio::time::sleep(Duration::from_secs(1)).await;
+                }
+            }
+        })
+    };
+
+    let _ = tokio::join!(
+        task_listener,
+        task_guardian_watcher,
+        task_price_feeds_metadata_updater
+    );
     Ok(())
 }
+
+
+pub async fn fetch_and_store_price_feeds_metadata(
+    state: &State,
+    mapping_address: &Pubkey,
+    rpc_client: &RpcClient,
+) -> Result<Vec<PriceFeedMetadata>> {
+    let price_feeds_metadata = fetch_price_feeds_metadata(&mapping_address, &rpc_client).await?;
+    store_price_feeds_metadata(&state, &price_feeds_metadata).await?;
+    Ok(price_feeds_metadata)
+}
+
+async fn fetch_price_feeds_metadata(
+    mapping_address: &Pubkey,
+    rpc_client: &RpcClient,
+) -> Result<Vec<PriceFeedMetadata>> {
+    let mut price_feeds_metadata = Vec::<PriceFeedMetadata>::new();
+    let mapping_data = rpc_client.get_account_data(mapping_address).await?;
+    let mapping_acct = load_mapping_account(&mapping_data)?;
+
+    // Split product keys into chunks of 150 to avoid too many open files error (error trying to connect: tcp open error: Too many open files (os error 24))
+    for product_keys_chunk in mapping_acct
+        .products
+        .iter()
+        .filter(|&prod_pkey| *prod_pkey != Pubkey::default())
+        .collect::<Vec<_>>()
+        .chunks(150)
+    {
+        // Prepare a list of futures for fetching product account data for each chunk
+        let fetch_product_data_futures = product_keys_chunk
+            .iter()
+            .map(|prod_pkey| rpc_client.get_account_data(prod_pkey))
+            .collect::<Vec<_>>();
+
+        // Await all futures concurrently within the chunk
+        let products_data_results = futures::future::join_all(fetch_product_data_futures).await;
+
+        for prod_data_result in products_data_results {
+            match prod_data_result {
+                Ok(prod_data) => {
+                    let prod_acct = match load_product_account(&prod_data) {
+                        Ok(prod_acct) => prod_acct,
+                        Err(e) => {
+                            println!("Error loading product account: {}", e);
+                            continue;
+                        }
+                    };
+
+                    // TODO: Add stricter type checking for attributes
+                    let attributes = prod_acct
+                        .iter()
+                        .filter(|(key, _)| !key.is_empty())
+                        .map(|(key, val)| (key.to_string(), val.to_string()))
+                        .collect::<BTreeMap<String, String>>();
+
+                    if prod_acct.px_acc != Pubkey::default() {
+                        let px_pkey = prod_acct.px_acc;
+                        let px_pkey_bytes = bs58::decode(&px_pkey.to_string()).into_vec()?;
+                        let px_pkey_array: [u8; 32] = px_pkey_bytes
+                            .try_into()
+                            .expect("Invalid length for PriceIdentifier");
+
+                        let price_feed_metadata = PriceFeedMetadata {
+                            id: PriceIdentifier::new(px_pkey_array),
+                            attributes,
+                        };
+
+                        price_feeds_metadata.push(price_feed_metadata);
+                    }
+                }
+                Err(e) => {
+                    println!("Error loading product account: {}", e);
+                    continue;
+                }
+            }
+        }
+    }
+    Ok(price_feeds_metadata)
+}

+ 55 - 0
hermes/src/price_feeds_metadata.rs

@@ -0,0 +1,55 @@
+use {
+    crate::{
+        api::types::{
+            AssetType,
+            PriceFeedMetadata,
+        },
+        state::State,
+    },
+    anyhow::Result,
+};
+
+pub const DEFAULT_PRICE_FEEDS_CACHE_UPDATE_INTERVAL: u16 = 600;
+
+pub async fn retrieve_price_feeds_metadata(state: &State) -> Result<Vec<PriceFeedMetadata>> {
+    let price_feeds_metadata = state.price_feeds_metadata.read().await;
+    Ok(price_feeds_metadata.clone())
+}
+
+pub async fn store_price_feeds_metadata(
+    state: &State,
+    price_feeds_metadata: &[PriceFeedMetadata],
+) -> Result<()> {
+    let mut price_feeds_metadata_write_guard = state.price_feeds_metadata.write().await;
+    *price_feeds_metadata_write_guard = price_feeds_metadata.to_vec();
+    Ok(())
+}
+
+
+pub async fn get_price_feeds_metadata(
+    state: &State,
+    query: Option<String>,
+    asset_type: Option<AssetType>,
+) -> Result<Vec<PriceFeedMetadata>> {
+    let mut price_feeds_metadata = retrieve_price_feeds_metadata(state).await?;
+
+    // Filter by query if provided
+    if let Some(query_str) = &query {
+        price_feeds_metadata.retain(|feed| {
+            feed.attributes.get("symbol").map_or(false, |symbol| {
+                symbol.to_lowercase().contains(&query_str.to_lowercase())
+            })
+        });
+    }
+
+    // Filter by asset_type if provided
+    if let Some(asset_type) = &asset_type {
+        price_feeds_metadata.retain(|feed| {
+            feed.attributes.get("asset_type").map_or(false, |type_str| {
+                type_str.to_lowercase() == asset_type.to_string().to_lowercase()
+            })
+        });
+    }
+
+    Ok(price_feeds_metadata)
+}

+ 5 - 0
hermes/src/state.rs

@@ -7,6 +7,7 @@ use {
             AggregateState,
             AggregationEvent,
         },
+        api::types::PriceFeedMetadata,
         network::wormhole::GuardianSet,
     },
     prometheus_client::registry::Registry,
@@ -50,6 +51,9 @@ pub struct State {
 
     /// Metrics registry
     pub metrics_registry: RwLock<Registry>,
+
+    /// Price feeds metadata
+    pub price_feeds_metadata: RwLock<Vec<PriceFeedMetadata>>,
 }
 
 impl State {
@@ -67,6 +71,7 @@ impl State {
             aggregate_state: RwLock::new(AggregateState::new(&mut metrics_registry)),
             benchmarks_endpoint,
             metrics_registry: RwLock::new(metrics_registry),
+            price_feeds_metadata: RwLock::new(Default::default()),
         })
     }
 }

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff