diff --git a/toolshed/Cargo.toml b/toolshed/Cargo.toml index cd0e7c9..a2ba09d 100644 --- a/toolshed/Cargo.toml +++ b/toolshed/Cargo.toml @@ -26,4 +26,5 @@ url = { version = "2.4.1", optional = true } async-graphql = "6.0.11" [dev-dependencies] +assert_matches = "1.5.0" rand = { version = "0.8.5", features = ["small_rng"] } diff --git a/toolshed/src/thegraph.rs b/toolshed/src/thegraph.rs new file mode 100644 index 0000000..46b8b94 --- /dev/null +++ b/toolshed/src/thegraph.rs @@ -0,0 +1,8 @@ +pub use block_pointer::*; +pub use deployment_id::*; +pub use subgraph_id::*; + +pub mod attestation; +pub mod block_pointer; +pub mod deployment_id; +pub mod subgraph_id; diff --git a/toolshed/src/thegraph/attestation.rs b/toolshed/src/thegraph/attestation.rs index e96014c..8f9764d 100644 --- a/toolshed/src/thegraph/attestation.rs +++ b/toolshed/src/thegraph/attestation.rs @@ -7,7 +7,7 @@ use ethers_core::{ use serde::{Deserialize, Serialize}; use thiserror::Error; -use super::DeploymentId; +use super::deployment_id::DeploymentId; #[derive(Clone, Debug, Deserialize, Serialize)] pub struct Attestation { @@ -113,7 +113,7 @@ pub fn verify( } #[cfg(test)] -mod test { +mod tests { use super::*; fn domain() -> Eip712Domain { diff --git a/toolshed/src/thegraph/block_pointer.rs b/toolshed/src/thegraph/block_pointer.rs new file mode 100644 index 0000000..4d8fbb0 --- /dev/null +++ b/toolshed/src/thegraph/block_pointer.rs @@ -0,0 +1,8 @@ +use alloy_primitives::{BlockHash, BlockNumber}; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub struct BlockPointer { + pub number: BlockNumber, + pub hash: BlockHash, +} diff --git a/toolshed/src/thegraph/deployment_id.rs b/toolshed/src/thegraph/deployment_id.rs new file mode 100644 index 0000000..b6da912 --- /dev/null +++ b/toolshed/src/thegraph/deployment_id.rs @@ -0,0 +1,254 @@ +use alloy_primitives::B256; +use async_graphql::Scalar; +use serde_with::{DeserializeFromStr, SerializeDisplay}; + +/// A Subgraph's Deployment ID represents unique identifier for a deployed subgraph on The Graph. +/// This is the content ID of the subgraph's manifest. +#[derive( + Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, SerializeDisplay, DeserializeFromStr, +)] +pub struct DeploymentId(pub B256); + +fn parse_cidv0(value: &str) -> Result { + if value.len() != 46 { + return Err(DeploymentIdError::InvalidIpfsHashLength { + value: value.to_string(), + length: value.len(), + }); + } + + let mut decoded = [0_u8; 34]; + bs58::decode(value) + .onto(&mut decoded) + .map_err(|e| DeploymentIdError::InvalidIpfsHash { + value: value.to_string(), + error: e, + })?; + let mut bytes = [0_u8; 32]; + bytes.copy_from_slice(&decoded[2..]); + + Ok(bytes.into()) +} + +/// Attempt to parse a 32-byte hex string. +fn parse_hexstr(value: &str) -> Result { + value + .parse::() + .map_err(|e| DeploymentIdError::InvalidHexString { + value: value.to_string(), + error: format!("{}", e), + }) +} + +/// Format bytes as a CIDv0. +fn format_cidv0(bytes: B256) -> String { + let mut buf = [0_u8; 34]; + buf[0..2].copy_from_slice(&[0x12, 0x20]); + buf[2..].copy_from_slice(bytes.as_slice()); + bs58::encode(buf).into_string() +} + +/// Subgraph deployment ID parsing error. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum DeploymentIdError { + /// Invalid IPFS hash length. The input string must 46 characters long. + #[error("invalid IPFS / CIDv0 hash length {length}: {value} (length must be 46)")] + InvalidIpfsHashLength { value: String, length: usize }, + + /// Invalid IPFS hash format. The input hash string could not be decoded as a CIDv0. + #[error("invalid IPFS / CIDv0 hash \"{value}\": {error}")] + InvalidIpfsHash { + value: String, + error: bs58::decode::Error, + }, + + /// Invalid hex string format. The input hex string could not be decoded. + #[error("invalid hex string \"{value}\": {error}")] + InvalidHexString { value: String, error: String }, +} + +impl std::str::FromStr for DeploymentId { + type Err = DeploymentIdError; + + /// Parse a deployment ID from a 32-byte hex string or a base58-encoded IPFS hash (CIDv0). + fn from_str(hash: &str) -> Result { + if hash.starts_with("Qm") { + // Attempt to decode IPFS hash (CIDv0) + Ok(Self(parse_cidv0(hash)?)) + } else { + // Attempt to decode 32-byte hex string + Ok(Self(parse_hexstr(hash)?)) + } + } +} + +impl std::fmt::Display for DeploymentId { + /// Encode the deployment ID as CIDv0 (base58-encoded sha256-hash). + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&format_cidv0(self.0)) + } +} + +impl std::fmt::Debug for DeploymentId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self) + } +} + +impl std::fmt::LowerHex for DeploymentId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::LowerHex::fmt(&self.0, f) + } +} + +#[Scalar] +impl async_graphql::ScalarType for DeploymentId { + fn parse(value: async_graphql::Value) -> async_graphql::InputValueResult { + if let async_graphql::Value::String(value) = &value { + Ok(value.parse::()?) + } else { + Err(async_graphql::InputValueError::expected_type(value)) + } + } + + fn to_value(&self) -> async_graphql::Value { + // Convert to CIDv0 (Qm... base58-encoded sha256-hash) + async_graphql::Value::String(self.to_string()) + } +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use alloy_primitives::B256; + use assert_matches::assert_matches; + + use super::{format_cidv0, parse_cidv0, parse_hexstr, DeploymentId, DeploymentIdError}; + + const VALID_CID: &str = "QmWmyoMoctfbAaiEs2G46gpeUmhqFRDW6KWo64y5r581Vz"; + const VALID_HEX: &str = "0x7d5a99f603f231d53a4f39d1521f98d2e8bb279cf29bebfd0687dc98458e7f89"; + + #[test] + fn parse_valid_cidv0() { + //// Given + let valid_cid = VALID_CID; + let expected_bytes = VALID_HEX.parse::().unwrap(); + + //// When + let parsed_id = parse_cidv0(valid_cid); + + //// Then + assert_matches!(parsed_id, Ok(id) => { + assert_eq!(id, expected_bytes); + }); + } + + #[test] + fn parse_invalid_lenght_cidv0() { + //// Given + let invalid_cid = "QmA"; + + //// When + let parsed_id = parse_cidv0(invalid_cid); + + //// Then + assert_matches!(parsed_id, Err(err) => { + assert_eq!(err, DeploymentIdError::InvalidIpfsHashLength { + value: invalid_cid.to_string(), + length: invalid_cid.len(), + }); + }); + } + + #[test] + fn parse_invalid_encoding_cidv0() { + //// Given + let invalid_cid = "QmfVqZ9gPyMdU6TznRUh+Y0ui7J5zym+v9BofcmEWOf4k="; + + //// When + let parsed_id = parse_cidv0(invalid_cid); + + //// Then + assert_matches!(parsed_id, Err(err) => { + assert_eq!(err, DeploymentIdError::InvalidIpfsHash { + value: invalid_cid.to_string(), + error: bs58::decode::Error::InvalidCharacter { + character: '+', + index: 20, + }, + }); + }); + } + + #[test] + fn parse_valid_hexstr() { + //// Given + let valid_hex = VALID_HEX; + let expected_bytes = VALID_HEX.parse::().unwrap(); + + //// When + let parsed_id = parse_hexstr(valid_hex); + + //// Then + assert_matches!(parsed_id, Ok(id) => { + assert_eq!(id, expected_bytes); + }); + } + + #[test] + fn parse_invalid_hexstr() { + //// Given + let invalid_hex = "0x0123456789ABCDEF"; + + //// When + let parsed_id = parse_hexstr(invalid_hex); + + //// Then + assert_matches!(parsed_id, Err(err) => { + assert_eq!(err, DeploymentIdError::InvalidHexString { + value: invalid_hex.to_string(), + error: "Invalid string length".to_string(), + }); + }); + } + + #[test] + fn format_into_cidv0() { + //// Given + let bytes = VALID_HEX.parse::().unwrap(); + let expected_cid = VALID_CID; + + //// When + let cid = format_cidv0(bytes); + + //// Then + assert_eq!(cid, expected_cid); + } + + #[test] + fn deployment_id_equality() { + //// Given + let valid_cid = VALID_CID; + let valid_hex = VALID_HEX; + + let expected_id = DeploymentId(VALID_HEX.parse().unwrap()); + let expected_repr = VALID_CID; + + //// When + let parsed_id1 = DeploymentId::from_str(valid_cid); + let parsed_id2 = DeploymentId::from_str(valid_hex); + + //// Then + assert_matches!((parsed_id1, parsed_id2), (Ok(id1), Ok(id2)) => { + // Assert the two IDs internal representation is correct + assert_eq!(id1, expected_id); + assert_eq!(id2, expected_id); + + // Assert the two IDs are equal and displayed in CIDv0 format + assert_eq!(id1, id2); + assert_eq!(id1.to_string(), expected_repr); + assert_eq!(id2.to_string(), expected_repr); + }); + } +} diff --git a/toolshed/src/thegraph/mod.rs b/toolshed/src/thegraph/mod.rs deleted file mode 100644 index 01ca911..0000000 --- a/toolshed/src/thegraph/mod.rs +++ /dev/null @@ -1,213 +0,0 @@ -pub mod attestation; - -use std::{fmt, fmt::LowerHex, str::FromStr}; - -use alloy_primitives::{Address, BlockHash, BlockNumber, B256}; -use async_graphql::{InputValueError, InputValueResult, Scalar, ScalarType, Value}; -use serde::{Deserialize, Serialize}; -use serde_with::{DeserializeFromStr, SerializeDisplay}; -use sha3::{ - digest::{Digest as _, Update as _}, - Keccak256, -}; -use thiserror::Error; - -#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] -pub struct BlockPointer { - pub number: BlockNumber, - pub hash: BlockHash, -} - -#[derive( - Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, SerializeDisplay, DeserializeFromStr, -)] -pub struct SubgraphId(pub B256); - -impl FromStr for SubgraphId { - type Err = &'static str; - fn from_str(s: &str) -> Result { - fn parse_v1(s: &str) -> Option { - // Attempt to decode v1 format: '0x' '-' - let (account_id, sequence_id) = s.split_once('-')?; - let account: Address = account_id.parse().ok()?; - // Assuming u256 big-endian, since that's the word-size of the EVM - let mut sequence_word = [0_u8; 32]; - let sequence_number = sequence_id.parse::().ok()?.to_be_bytes(); - sequence_word[24..].copy_from_slice(&sequence_number); - let hash: [u8; 32] = Keccak256::default() - .chain(account.0) - .chain(sequence_word) - .finalize() - .into(); - Some(hash.into()) - } - fn parse_v2(s: &str) -> Option { - // Attempt to decode v2 format: base58 of sha256 hash - let mut hash = [0_u8; 32]; - let len = bs58::decode(s).onto(&mut hash).ok()?; - hash.rotate_right(32 - len); - Some(hash.into()) - } - if let Some(v2) = parse_v2(s) { - return Ok(Self(v2)); - } - parse_v1(s).map(Self).ok_or("invalid subgraph ID") - } -} - -impl fmt::Display for SubgraphId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&bs58::encode(self.0.as_slice()).into_string()) - } -} - -impl fmt::Debug for SubgraphId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self) - } -} - -/// subgraph deployment hash, encoded/decoded using its CIDv0 representation -#[derive( - Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, SerializeDisplay, DeserializeFromStr, -)] -pub struct DeploymentId(pub B256); - -#[derive(Debug, Clone, PartialEq, Eq, Error)] -pub enum DeploymentIdError { - #[error("invalid IPFS / CIDv0 hash length {length}: {value} (length must be 46)")] - InvalidIpfsHashLength { value: String, length: usize }, - #[error("invalid IPFS / CIDv0 hash \"{value}\": {error}")] - InvalidIpfsHash { - value: String, - error: bs58::decode::Error, - }, - #[error("invalid hex string \"{value}\": {error}")] - InvalidHexString { value: String, error: String }, -} - -impl FromStr for DeploymentId { - type Err = DeploymentIdError; - fn from_str(s: &str) -> Result { - if s.starts_with("Qm") { - // Attempt to decode IPFS hash - if s.len() != 46 { - return Err(DeploymentIdError::InvalidIpfsHashLength { - value: s.to_string(), - length: s.len(), - }); - } - let mut decoded = [0_u8; 34]; - bs58::decode(s) - .onto(&mut decoded) - .map_err(|e| DeploymentIdError::InvalidIpfsHash { - value: s.to_string(), - error: e, - })?; - let mut bytes = [0_u8; 32]; - bytes.copy_from_slice(&decoded[2..]); - Ok(Self(bytes.into())) - } else { - // Attempt to decode 32-byte hex string - Ok(s.parse::() - .map(Self) - .map_err(|e| DeploymentIdError::InvalidHexString { - value: s.to_string(), - error: format!("{}", e), - })?) - } - } -} - -impl fmt::Display for DeploymentId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let mut buf = [0_u8; 34]; - buf[0..2].copy_from_slice(&[0x12, 0x20]); - buf[2..].copy_from_slice(self.0.as_slice()); - f.write_str(&bs58::encode(buf).into_string()) - } -} - -impl fmt::Debug for DeploymentId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self) - } -} - -impl LowerHex for DeploymentId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - fmt::LowerHex::fmt(&self.0, f) - } -} - -#[Scalar] -impl ScalarType for DeploymentId { - fn parse(value: Value) -> InputValueResult { - if let Value::String(value) = &value { - Ok(DeploymentId::from_str(value)?) - } else { - Err(InputValueError::expected_type(value)) - } - } - - fn to_value(&self) -> Value { - // Convert to Qm... - Value::String(self.to_string()) - } -} - -#[test] -fn subgraph_id_encode() { - let bytes: B256 = "0x67486e65165b1474898247760a4b852d70d95782c6325960e5b6b4fd82fed1bd" - .parse() - .unwrap(); - let v1 = "0xdeadbeef678b513255cea949017921c8c9f6ef82-1"; - let v2 = "7xB3yxxD8okmq4dZPky3eP1nYRgLfZrwMyUQBGo32t4U"; - - let id1: SubgraphId = v1.parse().unwrap(); - let id2: SubgraphId = v2.parse().unwrap(); - - assert_eq!(id1.0, bytes); - assert_eq!(&id1.to_string(), v2); - assert_eq!(id2.0, bytes); - assert_eq!(&id2.to_string(), v2); - assert_eq!(id1, id2); -} - -#[test] -fn deployment_id_decode_and_encode() { - let cid = "QmWmyoMoctfbAaiEs2G46gpeUmhqFRDW6KWo64y5r581Vz"; - let id_from_cid = DeploymentId::from_str(cid).expect("parsing from IPFS hash"); - - let hex = "0x7d5a99f603f231d53a4f39d1521f98d2e8bb279cf29bebfd0687dc98458e7f89"; - let id_from_hex = DeploymentId::from_str(hex).expect("parsing from hex string"); - - let bytes: B256 = hex.parse().expect("parsing hex string into bytes"); - - assert_eq!(id_from_cid, id_from_hex); - - assert_eq!(id_from_cid.to_string(), cid); - assert_eq!(id_from_hex.to_string(), cid); - - assert_eq!(format!("{id_from_cid:#x}"), hex); - assert_eq!(format!("{id_from_hex:#x}"), hex); - - assert_eq!(id_from_cid.0, bytes); - assert_eq!(id_from_hex.0, bytes); - - assert_eq!( - DeploymentId::from_str("QmA"), - Err(DeploymentIdError::InvalidIpfsHashLength { - value: "QmA".to_string(), - length: 3 - }) - ); - - assert_eq!( - DeploymentId::from_str("0x"), - Err(DeploymentIdError::InvalidHexString { - value: "0x".to_string(), - error: "Invalid string length".to_string() - }) - ); -} diff --git a/toolshed/src/thegraph/subgraph_id.rs b/toolshed/src/thegraph/subgraph_id.rs new file mode 100644 index 0000000..d828774 --- /dev/null +++ b/toolshed/src/thegraph/subgraph_id.rs @@ -0,0 +1,196 @@ +use alloy_primitives::{Address, B256}; +use serde_with::{DeserializeFromStr, SerializeDisplay}; +use sha3::{Digest as _, Keccak256}; + +#[derive( + Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, SerializeDisplay, DeserializeFromStr, +)] +pub struct SubgraphId(pub B256); + +/// Attempt to parse a Subgraph ID in v1 format: +/// ```text +/// 0x - +/// ``` +fn parse_v1(value: &str) -> Option { + let (account_id, sequence_id) = value.split_once('-')?; + let account = account_id.parse::
().ok()?; + + // Assuming u256 big-endian, since that's the word-size of the EVM + let mut sequence_word = [0_u8; 32]; + let sequence_number = sequence_id.parse::().ok()?.to_be_bytes(); + sequence_word[24..].copy_from_slice(&sequence_number); + + let hash: [u8; 32] = { + let mut hasher = Keccak256::default(); + hasher.update(account.0); + hasher.update(sequence_word); + hasher.finalize().into() + }; + + Some(hash.into()) +} + +/// Attempt to parse a Subgraph ID in v2 format: +/// +/// ```text +/// base58(sha256()) +/// ``` +/// +/// If the input is not valid base58, or the decoded hash is not 32 bytes, returns `None`. +fn parse_v2(value: &str) -> Option { + let mut hash = [0_u8; 32]; + let len = bs58::decode(value).onto(&mut hash).ok()?; + hash.rotate_right(32 - len); + Some(hash.into()) +} + +impl std::str::FromStr for SubgraphId { + type Err = &'static str; + fn from_str(value: &str) -> Result { + if let Some(v2) = parse_v2(value) { + return Ok(Self(v2)); + } + if let Some(v1) = parse_v1(value) { + return Ok(Self(v1)); + } + Err("invalid subgraph ID") + } +} + +impl std::fmt::Display for SubgraphId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&bs58::encode(self.0.as_slice()).into_string()) + } +} + +impl std::fmt::Debug for SubgraphId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self) + } +} + +#[cfg(test)] +mod tests { + use alloy_primitives::B256; + use assert_matches::assert_matches; + + use super::{parse_v1, parse_v2, SubgraphId}; + + const ID_V1: &str = "0xdeadbeef678b513255cea949017921c8c9f6ef82-1"; + const ID_V2: &str = "7xB3yxxD8okmq4dZPky3eP1nYRgLfZrwMyUQBGo32t4U"; + + const EXPECTED_ID_BYTES: &str = + "0x67486e65165b1474898247760a4b852d70d95782c6325960e5b6b4fd82fed1bd"; + + #[test] + fn parse_valid_v1_id() { + //// Given + let valid_id = ID_V1; + let expected_id = EXPECTED_ID_BYTES.parse::().unwrap(); + + //// When + let parsed_id = parse_v1(valid_id); + + //// Then + assert_matches!(parsed_id, Some(id) => { + assert_eq!(id, expected_id); + }); + } + + #[test] + fn parse_invalid_v1_id() { + //// Given + let invalid_id = ID_V2; + + //// When + let parsed_id = parse_v1(invalid_id); + + //// Then + assert_matches!(parsed_id, None); + } + + #[test] + fn parse_valid_v2_id() { + //// Given + let valid_id = ID_V2; + let expected_id = EXPECTED_ID_BYTES.parse::().unwrap(); + + //// When + let parsed_id = parse_v2(valid_id); + + //// Then + assert_matches!(parsed_id, Some(id) => { + assert_eq!(id, expected_id); + }); + } + + #[test] + fn decode_subgraph_id_from_v1_string() { + //// Given + let valid_id = ID_V1; + let expected_id = EXPECTED_ID_BYTES.parse::().unwrap(); + + //// When + let parsed_id = valid_id.parse::(); + + //// Then + assert_matches!(parsed_id, Ok(id) => { + assert_eq!(id.0, expected_id); + }); + } + + #[test] + fn decode_subgraph_id_from_v2_string() { + //// Given + let valid_id = ID_V2; + let expected_id = EXPECTED_ID_BYTES.parse::().unwrap(); + + //// When + let parsed_id = valid_id.parse::(); + + //// Then + assert_matches!(parsed_id, Ok(id) => { + assert_eq!(id.0, expected_id); + }); + } + + #[test] + fn decode_failure_on_invalid_string() { + //// Given + let invalid_id = "invalid"; + + //// When + let parsed_id = invalid_id.parse::(); + + //// Then + assert_matches!(parsed_id, Err(err) => { + assert_eq!(err, "invalid subgraph ID"); + }); + } + + #[test] + fn subgraph_equality() { + //// Given + let valid_v1 = ID_V1; + let valid_v2 = ID_V2; + + let expected_id = SubgraphId(EXPECTED_ID_BYTES.parse().unwrap()); + let expected_repr = ID_V2; + + //// When + let parsed_id1 = valid_v1.parse::(); + let parsed_id2 = valid_v2.parse::(); + + //// Then + assert_matches!((parsed_id1, parsed_id2), (Ok(id1), Ok(id2)) => { + // Assert the two IDs internal representation is correct + assert_eq!(id1, expected_id); + assert_eq!(id2, expected_id); + + // Assert the two IDs are equal and displayed in v2 format + assert_eq!(id1, id2); + assert_eq!(id1.to_string(), expected_repr); + assert_eq!(id2.to_string(), expected_repr); + }); + } +}