Sfoglia il codice sorgente

[fortuna] Add tests (#1133)

* fix stuff

* ok that was annoying

* ok

* stuff

* better tests

* cleanup

* pr comments
Jayant Krishnamurthy 2 anni fa
parent
commit
a22b202772

+ 55 - 2
fortuna/Cargo.lock

@@ -144,9 +144,9 @@ dependencies = [
 
 [[package]]
 name = "async-trait"
-version = "0.1.73"
+version = "0.1.74"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bc00ceb34980c03614e35a3a4e218276a0a824e911d07651cd0d858a51e8c0f0"
+checksum = "a66537f1bb974b254c98ed142ff995236e81b9d0fe4db0575f46612cb15eb0f9"
 dependencies = [
  "proc-macro2",
  "quote",
@@ -164,6 +164,12 @@ dependencies = [
  "rustc_version",
 ]
 
+[[package]]
+name = "auto-future"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c1e7e457ea78e524f48639f551fd79703ac3f2237f5ecccdf4708f8a75ad373"
+
 [[package]]
 name = "auto_impl"
 version = "1.1.0"
@@ -247,6 +253,30 @@ dependencies = [
  "syn 2.0.38",
 ]
 
+[[package]]
+name = "axum-test"
+version = "13.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e559a1b9b6e81018cd95f2528fc7b333e181191175f34daa9cf3369c7a18fd5"
+dependencies = [
+ "anyhow",
+ "async-trait",
+ "auto-future",
+ "axum",
+ "bytes",
+ "cookie",
+ "http",
+ "hyper",
+ "reserve-port",
+ "serde",
+ "serde_json",
+ "serde_urlencoded",
+ "smallvec",
+ "tokio",
+ "tower",
+ "url",
+]
+
 [[package]]
 name = "backtrace"
 version = "0.3.69"
@@ -665,6 +695,16 @@ version = "0.1.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc"
 
+[[package]]
+name = "cookie"
+version = "0.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7efb37c3e1ccb1ff97164ad95ac1606e8ccd35b3fa0a7d99a304c7f4a428cc24"
+dependencies = [
+ "time",
+ "version_check",
+]
+
 [[package]]
 name = "core-foundation"
 version = "0.9.3"
@@ -1451,12 +1491,15 @@ dependencies = [
  "anyhow",
  "axum",
  "axum-macros",
+ "axum-test",
  "base64 0.21.4",
  "byteorder",
  "clap",
  "ethabi",
  "ethers",
  "hex",
+ "lazy_static",
+ "once_cell",
  "prometheus-client",
  "pythnet-sdk",
  "rand",
@@ -2978,6 +3021,16 @@ dependencies = [
  "winreg",
 ]
 
+[[package]]
+name = "reserve-port"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b212efd3460286cd590149feedd0afabef08ee352445dd6b4452f0d136098a5f"
+dependencies = [
+ "lazy_static",
+ "thiserror",
+]
+
 [[package]]
 name = "rfc6979"
 version = "0.4.0"

+ 5 - 0
fortuna/Cargo.toml

@@ -29,3 +29,8 @@ tracing            = { version = "0.1.37", features = ["log"] }
 tracing-subscriber = { version = "0.3.17", features = ["env-filter"] }
 utoipa             = { version = "3.4.0", features = ["axum_extras"] }
 utoipa-swagger-ui  = { version = "3.1.4", features = ["axum"] }
+once_cell = "1.18.0"
+lazy_static = "1.4.0"
+
+[dev-dependencies]
+axum-test = "13.1.1"

+ 241 - 4
fortuna/src/api.rs

@@ -1,14 +1,17 @@
 use {
     crate::{
-        ethereum::PythContract,
+        chain::reader::EntropyReader,
         state::HashChainState,
     },
     axum::{
+        body::Body,
         http::StatusCode,
         response::{
             IntoResponse,
             Response,
         },
+        routing::get,
+        Router,
     },
     ethers::core::types::Address,
     prometheus_client::{
@@ -51,13 +54,24 @@ pub struct ApiState {
     pub metrics: Arc<Metrics>,
 }
 
+impl ApiState {
+    pub fn new(chains: &[(ChainId, BlockchainState)]) -> ApiState {
+        let map: HashMap<ChainId, BlockchainState> = chains.into_iter().cloned().collect();
+        ApiState {
+            chains:  Arc::new(map),
+            metrics: Arc::new(Metrics::new()),
+        }
+    }
+}
+
 /// The state of the randomness service for a single blockchain.
+#[derive(Clone)]
 pub struct BlockchainState {
     /// The hash chain(s) required to serve random numbers for this blockchain
     pub state:            Arc<HashChainState>,
-    /// The EVM contract where the protocol is running.
-    pub contract:         Arc<PythContract>,
-    /// The EVM address of the provider that this server is operating for.
+    /// The contract that the server is fulfilling requests for.
+    pub contract:         Arc<dyn EntropyReader>,
+    /// The address of the provider that this server is operating for.
     pub provider_address: Address,
 }
 
@@ -137,3 +151,226 @@ impl IntoResponse for RestError {
         }
     }
 }
+
+pub fn routes(state: ApiState) -> Router<(), Body> {
+    Router::new()
+        .route("/", get(index))
+        .route("/live", get(live))
+        .route("/metrics", get(metrics))
+        .route("/ready", get(ready))
+        .route("/v1/chains", get(chain_ids))
+        .route(
+            "/v1/chains/:chain_id/revelations/:sequence",
+            get(revelation),
+        )
+        .with_state(state)
+}
+
+#[cfg(test)]
+mod test {
+    use {
+        crate::{
+            api::{
+                self,
+                ApiState,
+                BinaryEncoding,
+                Blob,
+                BlockchainState,
+                GetRandomValueResponse,
+            },
+            chain::reader::mock::MockEntropyReader,
+            state::{
+                HashChainState,
+                PebbleHashChain,
+            },
+        },
+        axum::http::StatusCode,
+        axum_test::{
+            TestResponse,
+            TestServer,
+        },
+        ethers::prelude::Address,
+        lazy_static::lazy_static,
+        std::sync::Arc,
+    };
+
+    const PROVIDER: Address = Address::zero();
+    lazy_static! {
+        static ref OTHER_PROVIDER: Address = Address::from_low_u64_be(1);
+        // Note: these chains are immutable. They are wrapped in Arc because we need Arcs to
+        // initialize the BlockchainStates below, but they aren't cloneable (nor do they need to be cloned).
+        static ref ETH_CHAIN: Arc<HashChainState> = Arc::new(HashChainState::from_chain_at_offset(
+            0,
+            PebbleHashChain::new([0u8; 32], 1000),
+        ));
+        static ref AVAX_CHAIN: Arc<HashChainState> = Arc::new(HashChainState::from_chain_at_offset(
+            100,
+            PebbleHashChain::new([1u8; 32], 1000),
+        ));
+    }
+
+    fn test_server() -> (TestServer, Arc<MockEntropyReader>, Arc<MockEntropyReader>) {
+        let eth_read = Arc::new(MockEntropyReader::with_requests(&[]));
+
+        let eth_state = BlockchainState {
+            state:            ETH_CHAIN.clone(),
+            contract:         eth_read.clone(),
+            provider_address: PROVIDER,
+        };
+
+        let avax_read = Arc::new(MockEntropyReader::with_requests(&[]));
+
+        let avax_state = BlockchainState {
+            state:            AVAX_CHAIN.clone(),
+            contract:         avax_read.clone(),
+            provider_address: PROVIDER,
+        };
+
+        let api_state = ApiState::new(&[
+            ("ethereum".into(), eth_state),
+            ("avalanche".into(), avax_state),
+        ]);
+
+        let app = api::routes(api_state);
+        (TestServer::new(app).unwrap(), eth_read, avax_read)
+    }
+
+    async fn get_and_assert_status(
+        server: &TestServer,
+        path: &str,
+        status: StatusCode,
+    ) -> TestResponse {
+        let response = server.get(path).await;
+        response.assert_status(status);
+        response
+    }
+
+    #[tokio::test]
+    async fn test_revelation() {
+        let (server, eth_contract, avax_contract) = test_server();
+
+        // Can't access a revelation if it hasn't been requested
+        get_and_assert_status(
+            &server,
+            "/v1/chains/ethereum/revelations/0",
+            StatusCode::FORBIDDEN,
+        )
+        .await;
+
+        // Once someone requests the number, then it is accessible
+        eth_contract.insert(PROVIDER, 0);
+        let response =
+            get_and_assert_status(&server, "/v1/chains/ethereum/revelations/0", StatusCode::OK)
+                .await;
+        response.assert_json(&GetRandomValueResponse {
+            value: Blob::new(BinaryEncoding::Hex, ETH_CHAIN.reveal(0).unwrap()),
+        });
+
+        // Each chain and provider has its own set of requests
+        eth_contract.insert(PROVIDER, 100);
+        eth_contract.insert(*OTHER_PROVIDER, 101);
+        eth_contract.insert(PROVIDER, 102);
+        avax_contract.insert(PROVIDER, 102);
+        avax_contract.insert(PROVIDER, 103);
+        avax_contract.insert(*OTHER_PROVIDER, 104);
+
+        let response = get_and_assert_status(
+            &server,
+            "/v1/chains/ethereum/revelations/100",
+            StatusCode::OK,
+        )
+        .await;
+        response.assert_json(&GetRandomValueResponse {
+            value: Blob::new(BinaryEncoding::Hex, ETH_CHAIN.reveal(100).unwrap()),
+        });
+
+        get_and_assert_status(
+            &server,
+            "/v1/chains/ethereum/revelations/101",
+            StatusCode::FORBIDDEN,
+        )
+        .await;
+        let response = get_and_assert_status(
+            &server,
+            "/v1/chains/ethereum/revelations/102",
+            StatusCode::OK,
+        )
+        .await;
+        response.assert_json(&GetRandomValueResponse {
+            value: Blob::new(BinaryEncoding::Hex, ETH_CHAIN.reveal(102).unwrap()),
+        });
+        get_and_assert_status(
+            &server,
+            "/v1/chains/ethereum/revelations/103",
+            StatusCode::FORBIDDEN,
+        )
+        .await;
+        get_and_assert_status(
+            &server,
+            "/v1/chains/ethereum/revelations/104",
+            StatusCode::FORBIDDEN,
+        )
+        .await;
+
+        get_and_assert_status(
+            &server,
+            "/v1/chains/avalanche/revelations/100",
+            StatusCode::FORBIDDEN,
+        )
+        .await;
+        get_and_assert_status(
+            &server,
+            "/v1/chains/avalanche/revelations/101",
+            StatusCode::FORBIDDEN,
+        )
+        .await;
+        let response = get_and_assert_status(
+            &server,
+            "/v1/chains/avalanche/revelations/102",
+            StatusCode::OK,
+        )
+        .await;
+        response.assert_json(&GetRandomValueResponse {
+            value: Blob::new(BinaryEncoding::Hex, AVAX_CHAIN.reveal(102).unwrap()),
+        });
+        let response = get_and_assert_status(
+            &server,
+            "/v1/chains/avalanche/revelations/103",
+            StatusCode::OK,
+        )
+        .await;
+        response.assert_json(&GetRandomValueResponse {
+            value: Blob::new(BinaryEncoding::Hex, AVAX_CHAIN.reveal(103).unwrap()),
+        });
+        get_and_assert_status(
+            &server,
+            "/v1/chains/avalanche/revelations/104",
+            StatusCode::FORBIDDEN,
+        )
+        .await;
+
+        // Bad chain ids fail
+        get_and_assert_status(
+            &server,
+            "/v1/chains/not_a_chain/revelations/0",
+            StatusCode::BAD_REQUEST,
+        )
+        .await;
+
+        // Requesting a number that has a request, but isn't in the HashChainState also fails.
+        // (Note that this shouldn't happen in normal operation)
+        get_and_assert_status(
+            &server,
+            "/v1/chains/avalanche/revelations/99",
+            StatusCode::FORBIDDEN,
+        )
+        .await;
+        avax_contract.insert(PROVIDER, 99);
+        get_and_assert_status(
+            &server,
+            "/v1/chains/avalanche/revelations/99",
+            StatusCode::INTERNAL_SERVER_ERROR,
+        )
+        .await;
+    }
+}

+ 15 - 17
fortuna/src/api/revelation.rs

@@ -59,26 +59,25 @@ pub async fn revelation(
         .get(&chain_id)
         .ok_or_else(|| RestError::InvalidChainId)?;
 
-    let r = state
+    let maybe_request = state
         .contract
         .get_request(state.provider_address, sequence)
-        .call()
         .await
         .map_err(|_| RestError::TemporarilyUnavailable)?;
 
-    // sequence_number == 0 means the request does not exist.
-    if r.sequence_number != 0 {
-        let value = &state
-            .state
-            .reveal(sequence)
-            .map_err(|_| RestError::Unknown)?;
-        let encoded_value = Blob::new(encoding.unwrap_or(BinaryEncoding::Hex), value.clone());
+    match maybe_request {
+        Some(_) => {
+            let value = &state
+                .state
+                .reveal(sequence)
+                .map_err(|_| RestError::Unknown)?;
+            let encoded_value = Blob::new(encoding.unwrap_or(BinaryEncoding::Hex), value.clone());
 
-        Ok(Json(GetRandomValueResponse {
-            value: encoded_value,
-        }))
-    } else {
-        Err(RestError::NoPendingRequest)
+            Ok(Json(GetRandomValueResponse {
+                value: encoded_value,
+            }))
+        }
+        None => Err(RestError::NoPendingRequest),
     }
 }
 
@@ -107,14 +106,13 @@ pub enum BinaryEncoding {
     Array,
 }
 
-#[derive(Debug, serde::Serialize, serde::Deserialize, ToSchema)]
+#[derive(Debug, serde::Serialize, serde::Deserialize, ToSchema, PartialEq)]
 pub struct GetRandomValueResponse {
-    // TODO: choose serialization format
     pub value: Blob,
 }
 
 #[serde_as]
-#[derive(Debug, serde::Serialize, serde::Deserialize, ToSchema)]
+#[derive(Debug, serde::Serialize, serde::Deserialize, ToSchema, PartialEq)]
 #[serde(tag = "encoding", rename_all = "kebab-case")]
 pub enum Blob {
     Hex {

+ 2 - 0
fortuna/src/chain.rs

@@ -0,0 +1,2 @@
+pub(crate) mod ethereum;
+pub(crate) mod reader;

+ 33 - 2
fortuna/src/ethereum.rs → fortuna/src/chain/ethereum.rs

@@ -1,9 +1,16 @@
 use {
-    crate::config::EthereumConfig,
+    crate::{
+        chain::{
+            reader,
+            reader::EntropyReader,
+        },
+        config::EthereumConfig,
+    },
     anyhow::{
         anyhow,
         Result,
     },
+    axum::async_trait,
     ethers::{
         abi::RawLog,
         contract::{
@@ -38,7 +45,7 @@ use {
     std::sync::Arc,
 };
 
-// TODO: Programatically generate this so we don't have to keep committed ABI in sync with the
+// TODO: Programmatically generate this so we don't have to keep committed ABI in sync with the
 // contract in the same repo.
 abigen!(PythRandom, "src/abi.json");
 
@@ -169,3 +176,27 @@ impl PythContract {
         ))
     }
 }
+
+#[async_trait]
+impl EntropyReader for PythContract {
+    async fn get_request(
+        &self,
+        provider_address: Address,
+        sequence_number: u64,
+    ) -> Result<Option<reader::Request>> {
+        let r = self
+            .get_request(provider_address, sequence_number)
+            .call()
+            .await?;
+
+        // sequence_number == 0 means the request does not exist.
+        if r.sequence_number != 0 {
+            Ok(Some(reader::Request {
+                provider:        r.provider,
+                sequence_number: r.sequence_number,
+            }))
+        } else {
+            Ok(None)
+        }
+    }
+}

+ 89 - 0
fortuna/src/chain/reader.rs

@@ -0,0 +1,89 @@
+use {
+    anyhow::Result,
+    axum::async_trait,
+    ethers::types::Address,
+};
+
+/// EntropyReader is the read-only interface of the Entropy contract.
+#[async_trait]
+pub trait EntropyReader: Send + Sync {
+    /// Get an in-flight request (if it exists)
+    /// Note that if we support additional blockchains in the future, the type of `provider` may
+    /// need to become more generic.
+    async fn get_request(&self, provider: Address, sequence_number: u64)
+        -> Result<Option<Request>>;
+}
+
+/// An in-flight request stored in the contract.
+/// (This struct is missing many fields that are defined in the contract, as they
+/// aren't used in fortuna anywhere. Feel free to add any missing fields as necessary.)
+#[derive(Clone, Debug)]
+pub struct Request {
+    pub provider:        Address,
+    pub sequence_number: u64,
+}
+
+
+#[cfg(test)]
+pub mod mock {
+    use {
+        crate::chain::reader::{
+            EntropyReader,
+            Request,
+        },
+        anyhow::Result,
+        axum::async_trait,
+        ethers::types::Address,
+        std::sync::RwLock,
+    };
+
+    /// Mock version of the entropy contract intended for testing.
+    /// This class is internally locked to allow tests to modify the in-flight requests while
+    /// the API is also holding a pointer to the same data structure.
+    pub struct MockEntropyReader {
+        /// The set of requests that are currently in-flight.
+        requests: RwLock<Vec<Request>>,
+    }
+
+    impl MockEntropyReader {
+        pub fn with_requests(requests: &[(Address, u64)]) -> MockEntropyReader {
+            MockEntropyReader {
+                requests: RwLock::new(
+                    requests
+                        .iter()
+                        .map(|&(a, s)| Request {
+                            provider:        a,
+                            sequence_number: s,
+                        })
+                        .collect(),
+                ),
+            }
+        }
+
+        /// Insert a new request into the set of in-flight requests.
+        pub fn insert(&self, provider: Address, sequence: u64) -> &Self {
+            self.requests.write().unwrap().push(Request {
+                provider,
+                sequence_number: sequence,
+            });
+            self
+        }
+    }
+
+    #[async_trait]
+    impl EntropyReader for MockEntropyReader {
+        async fn get_request(
+            &self,
+            provider: Address,
+            sequence_number: u64,
+        ) -> Result<Option<Request>> {
+            Ok(self
+                .requests
+                .read()
+                .unwrap()
+                .iter()
+                .find(|&r| r.sequence_number == sequence_number && r.provider == provider)
+                .map(|r| (*r).clone()))
+        }
+    }
+}

+ 1 - 1
fortuna/src/command/generate.rs

@@ -1,11 +1,11 @@
 use {
     crate::{
         api::GetRandomValueResponse,
+        chain::ethereum::SignablePythContract,
         config::{
             Config,
             GenerateOptions,
         },
-        ethereum::SignablePythContract,
     },
     anyhow::Result,
     base64::{

+ 1 - 1
fortuna/src/command/get_request.rs

@@ -1,10 +1,10 @@
 use {
     crate::{
+        chain::ethereum::PythContract,
         config::{
             Config,
             GetRequestOptions,
         },
-        ethereum::PythContract,
     },
     anyhow::Result,
     std::sync::Arc,

+ 1 - 1
fortuna/src/command/register_provider.rs

@@ -1,10 +1,10 @@
 use {
     crate::{
+        chain::ethereum::SignablePythContract,
         config::{
             Config,
             RegisterProviderOptions,
         },
-        ethereum::SignablePythContract,
         state::PebbleHashChain,
     },
     anyhow::Result,

+ 1 - 1
fortuna/src/command/request_randomness.rs

@@ -1,10 +1,10 @@
 use {
     crate::{
+        chain::ethereum::SignablePythContract,
         config::{
             Config,
             RequestRandomnessOptions,
         },
-        ethereum::SignablePythContract,
     },
     anyhow::Result,
     std::sync::Arc,

+ 4 - 16
fortuna/src/command/run.rs

@@ -1,11 +1,11 @@
 use {
     crate::{
         api,
+        chain::ethereum::PythContract,
         config::{
             Config,
             RunOptions,
         },
-        ethereum::PythContract,
         state::{
             HashChainState,
             PebbleHashChain,
@@ -15,10 +15,7 @@ use {
         anyhow,
         Result,
     },
-    axum::{
-        routing::get,
-        Router,
-    },
+    axum::Router,
     std::{
         collections::HashMap,
         sync::Arc,
@@ -43,7 +40,7 @@ pub async fn run(opts: &RunOptions) -> Result<()> {
     )
     ),
     tags(
-    (name = "pyth-rng", description = "Pyth Random Number Service")
+    (name = "fortuna", description = "Random number service for the Pyth Entropy protocol")
     )
     )]
     struct ApiDoc;
@@ -99,16 +96,7 @@ pub async fn run(opts: &RunOptions) -> Result<()> {
     let app = Router::new();
     let app = app
         .merge(SwaggerUi::new("/docs").url("/docs/openapi.json", ApiDoc::openapi()))
-        .route("/", get(api::index))
-        .route("/live", get(api::live))
-        .route("/metrics", get(api::metrics))
-        .route("/ready", get(api::ready))
-        .route("/v1/chains", get(api::chain_ids))
-        .route(
-            "/v1/chains/:chain_id/revelations/:sequence",
-            get(api::revelation),
-        )
-        .with_state(api_state)
+        .merge(api::routes(api_state))
         // Permissive CORS layer to allow all origins
         .layer(CorsLayer::permissive());
 

+ 1 - 1
fortuna/src/main.rs

@@ -8,9 +8,9 @@ use {
 };
 
 pub mod api;
+pub mod chain;
 pub mod command;
 pub mod config;
-pub mod ethereum;
 pub mod state;
 
 // Server TODO list:

+ 8 - 0
fortuna/src/state.rs

@@ -14,6 +14,7 @@ use {
 };
 
 /// A HashChain.
+#[derive(Clone)]
 pub struct PebbleHashChain {
     hash: Vec<[u8; 32]>,
     next: usize,
@@ -76,6 +77,13 @@ pub struct HashChainState {
 }
 
 impl HashChainState {
+    pub fn from_chain_at_offset(offset: usize, chain: PebbleHashChain) -> HashChainState {
+        HashChainState {
+            offsets:     vec![offset],
+            hash_chains: vec![chain],
+        }
+    }
+
     pub fn reveal(&self, sequence_number: u64) -> Result<[u8; 32]> {
         let sequence_number: usize = sequence_number.try_into()?;
         let chain_index = self