From 6db46e763b2434b62c192b0aba22e4abcccdb482 Mon Sep 17 00:00:00 2001 From: Manolis Liolios Date: Thu, 29 Aug 2024 18:08:21 +0300 Subject: [PATCH] [GraphQL][DotMove] Introduces `DotMove` resolution (#18774) ## Description Introduces the logic for querying packages & types by name. Works for both internal & external resolution mode. Important: There's not yet an officially supported package for this functionality on mainnet. There'll be a follow-up PR to address this. ## Test plan There are both unit tests on name parsing, and e2e tests for package resolution. I test package upgrades, and resolution both with "latest", as well as on given fixed versions. ``` cargo nextest run --package sui-graphql-rpc --test dot_move_e2e --features pg_integration ``` ## Stack - #18770 --- ## Release notes Check each box that your changes affect. If none of the boxes relate to your changes, release notes aren't required. For each box you select, include information after the relevant heading that describes the impact of your changes that a user might notice and any actions they must take to implement updates. - [ ] Protocol: - [ ] Nodes (Validators and Full nodes): - [ ] Indexer: - [ ] JSON-RPC: - [x] GraphQL: Introduces `.move` name resolution (internal & external) for GraphQL. Only supported on a non-mainnet environment for the time being. - [ ] CLI: - [ ] Rust SDK: - [ ] REST API: --- .github/workflows/rust.yml | 2 +- Cargo.lock | 1 + crates/sui-cluster-test/src/cluster.rs | 3 +- .../src/simple_client.rs | 4 + crates/sui-graphql-rpc/Cargo.toml | 1 + crates/sui-graphql-rpc/schema.graphql | 12 + crates/sui-graphql-rpc/src/config.rs | 95 ++++ crates/sui-graphql-rpc/src/data.rs | 1 + .../src/data/move_registry_data_loader.rs | 177 ++++++ crates/sui-graphql-rpc/src/error.rs | 5 + .../sui-graphql-rpc/src/functional_group.rs | 6 + crates/sui-graphql-rpc/src/server/builder.rs | 6 +- .../sui-graphql-rpc/src/test_infra/cluster.rs | 101 +++- .../src/types/dot_move/error.rs | 35 ++ .../sui-graphql-rpc/src/types/dot_move/mod.rs | 7 + .../src/types/dot_move/named_move_package.rs | 126 +++++ .../src/types/dot_move/named_type.rs | 231 ++++++++ .../src/types/dot_move/on_chain.rs | 290 ++++++++++ crates/sui-graphql-rpc/src/types/mod.rs | 1 + crates/sui-graphql-rpc/src/types/query.rs | 23 + .../tests/dot_move/demo/Move.toml | 10 + .../tests/dot_move/demo/sources/demo.move | 7 + .../tests/dot_move/demo_v2/Move.toml | 10 + .../tests/dot_move/demo_v2/sources/demo.move | 8 + .../tests/dot_move/demo_v3/Move.toml | 10 + .../tests/dot_move/demo_v3/sources/demo.move | 9 + .../tests/dot_move/dot_move/Move.toml | 10 + .../dot_move/dot_move/sources/dotmove.move | 86 +++ .../tests/dot_move/dot_move/sources/name.move | 47 ++ crates/sui-graphql-rpc/tests/dot_move_e2e.rs | 513 ++++++++++++++++++ crates/sui-graphql-rpc/tests/e2e_tests.rs | 115 ++-- .../snapshot_tests__schema_sdl_export.snap | 13 +- crates/sui-types/src/collection_types.rs | 36 ++ crates/sui/src/sui_commands.rs | 4 +- 34 files changed, 1939 insertions(+), 66 deletions(-) create mode 100644 crates/sui-graphql-rpc/src/data/move_registry_data_loader.rs create mode 100644 crates/sui-graphql-rpc/src/types/dot_move/error.rs create mode 100644 crates/sui-graphql-rpc/src/types/dot_move/mod.rs create mode 100644 crates/sui-graphql-rpc/src/types/dot_move/named_move_package.rs create mode 100644 crates/sui-graphql-rpc/src/types/dot_move/named_type.rs create mode 100644 crates/sui-graphql-rpc/src/types/dot_move/on_chain.rs create mode 100644 crates/sui-graphql-rpc/tests/dot_move/demo/Move.toml create mode 100644 crates/sui-graphql-rpc/tests/dot_move/demo/sources/demo.move create mode 100644 crates/sui-graphql-rpc/tests/dot_move/demo_v2/Move.toml create mode 100644 crates/sui-graphql-rpc/tests/dot_move/demo_v2/sources/demo.move create mode 100644 crates/sui-graphql-rpc/tests/dot_move/demo_v3/Move.toml create mode 100644 crates/sui-graphql-rpc/tests/dot_move/demo_v3/sources/demo.move create mode 100644 crates/sui-graphql-rpc/tests/dot_move/dot_move/Move.toml create mode 100644 crates/sui-graphql-rpc/tests/dot_move/dot_move/sources/dotmove.move create mode 100644 crates/sui-graphql-rpc/tests/dot_move/dot_move/sources/name.move create mode 100644 crates/sui-graphql-rpc/tests/dot_move_e2e.rs diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index d2f1edbb88d63..3293978ad4597 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -452,7 +452,7 @@ jobs: - run: sleep 5 - name: tests-requiring-postgres run: | - cargo nextest run --test-threads 1 --package sui-graphql-rpc --test e2e_tests --test examples_validation_tests --features pg_integration + cargo nextest run --test-threads 1 --package sui-graphql-rpc --test e2e_tests --test examples_validation_tests --test dot_move_e2e --features pg_integration cargo nextest run --test-threads 1 --package sui-graphql-rpc --lib --features pg_integration -- test_query_cost cargo nextest run --test-threads 4 --package sui-graphql-e2e-tests --features pg_integration cargo nextest run --test-threads 1 --package sui-cluster-test --test local_cluster_test --features pg_integration diff --git a/Cargo.lock b/Cargo.lock index ad0751d09fc0e..e9e29bb47d69c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13577,6 +13577,7 @@ dependencies = [ "sui-indexer", "sui-json-rpc", "sui-json-rpc-types", + "sui-move-build", "sui-package-resolver", "sui-protocol-config", "sui-rest-api", diff --git a/crates/sui-cluster-test/src/cluster.rs b/crates/sui-cluster-test/src/cluster.rs index 6f1a43e7718a3..20daca8105d56 100644 --- a/crates/sui-cluster-test/src/cluster.rs +++ b/crates/sui-cluster-test/src/cluster.rs @@ -7,7 +7,7 @@ use std::net::SocketAddr; use std::path::Path; use sui_config::Config; use sui_config::{PersistedConfig, SUI_KEYSTORE_FILENAME, SUI_NETWORK_CONFIG}; -use sui_graphql_rpc::config::ConnectionConfig; +use sui_graphql_rpc::config::{ConnectionConfig, ServiceConfig}; use sui_graphql_rpc::test_infra::cluster::start_graphql_server_with_fn_rpc; use sui_indexer::test_utils::{start_test_indexer, ReaderWriterConfig}; use sui_keys::keystore::{AccountKeystore, FileBasedKeystore, Keystore}; @@ -260,6 +260,7 @@ impl Cluster for LocalNewCluster { graphql_connection_config.clone(), Some(fullnode_url.clone()), /* cancellation_token */ None, + ServiceConfig::test_defaults(), ) .await; } diff --git a/crates/sui-graphql-rpc-client/src/simple_client.rs b/crates/sui-graphql-rpc-client/src/simple_client.rs index 6f7d2054e5f8e..badafad03fee5 100644 --- a/crates/sui-graphql-rpc-client/src/simple_client.rs +++ b/crates/sui-graphql-rpc-client/src/simple_client.rs @@ -115,6 +115,10 @@ impl SimpleClient { .await?; Ok(()) } + + pub fn url(&self) -> String { + self.url.clone() + } } #[allow(clippy::type_complexity)] diff --git a/crates/sui-graphql-rpc/Cargo.toml b/crates/sui-graphql-rpc/Cargo.toml index 33c5876f5004b..bbc3993caa2e4 100644 --- a/crates/sui-graphql-rpc/Cargo.toml +++ b/crates/sui-graphql-rpc/Cargo.toml @@ -90,6 +90,7 @@ serde_json.workspace = true sui-framework.workspace = true tower.workspace = true sui-test-transaction-builder.workspace = true +sui-move-build.workspace = true [features] default = [] diff --git a/crates/sui-graphql-rpc/schema.graphql b/crates/sui-graphql-rpc/schema.graphql index faca90cc4e354..dd60212e8d3b8 100644 --- a/crates/sui-graphql-rpc/schema.graphql +++ b/crates/sui-graphql-rpc/schema.graphql @@ -1349,6 +1349,10 @@ enum Feature { validators either directly, or through system transactions. """ SYSTEM_STATE + """ + Named packages service (utilizing dotmove package registry). + """ + MOVE_REGISTRY } @@ -3347,6 +3351,14 @@ type Query { """ resolveSuinsAddress(domain: String!): Address """ + Fetch a package by its name (using dot move service) + """ + packageByName(name: String!): MovePackage + """ + Fetch a type that includes dot move service names in it. + """ + typeByName(name: String!): MoveType! + """ The coin metadata associated with the given coin type. """ coinMetadata(coinType: String!): CoinMetadata diff --git a/crates/sui-graphql-rpc/src/config.rs b/crates/sui-graphql-rpc/src/config.rs index 42f6feab0b084..2c35f1d860c1d 100644 --- a/crates/sui-graphql-rpc/src/config.rs +++ b/crates/sui-graphql-rpc/src/config.rs @@ -1,17 +1,32 @@ // Copyright (c) Mysten Labs, Inc. // SPDX-License-Identifier: Apache-2.0 +use std::str::FromStr; use crate::functional_group::FunctionalGroup; use async_graphql::*; use fastcrypto_zkp::bn254::zk_login_api::ZkLoginEnv; +use move_core_types::ident_str; +use move_core_types::identifier::IdentStr; use serde::{Deserialize, Serialize}; use std::{collections::BTreeSet, fmt::Display, time::Duration}; use sui_graphql_config::GraphQLConfig; use sui_json_rpc::name_service::NameServiceConfig; +use sui_types::base_types::{ObjectID, SuiAddress}; pub(crate) const RPC_TIMEOUT_ERR_SLEEP_RETRY_PERIOD: Duration = Duration::from_millis(10_000); pub(crate) const MAX_CONCURRENT_REQUESTS: usize = 1_000; +// Move Registry constants +pub(crate) const MOVE_REGISTRY_MODULE: &IdentStr = ident_str!("name"); +pub(crate) const MOVE_REGISTRY_TYPE: &IdentStr = ident_str!("Name"); +// TODO(manos): Replace with actual package id on mainnet. +const MOVE_REGISTRY_PACKAGE: &str = + "0x1a841abe817c38221596856bc975b3b84f2f68692191e9247e185213d3d02fd8"; +// TODO(manos): Replace with actual registry table id on mainnet. +const MOVE_REGISTRY_TABLE_ID: &str = + "0x250b60446b8e7b8d9d7251600a7228dbfda84ccb4b23a56a700d833e221fae4f"; +const DEFAULT_PAGE_LIMIT: u16 = 50; + /// The combination of all configurations for the GraphQL service. #[GraphQLConfig] #[derive(Default)] @@ -52,6 +67,7 @@ pub struct ServiceConfig { pub(crate) name_service: NameServiceConfig, pub(crate) background_tasks: BackgroundTasksConfig, pub(crate) zklogin: ZkLoginConfig, + pub(crate) move_registry: MoveRegistryConfig, } #[GraphQLConfig] @@ -106,6 +122,22 @@ pub struct BackgroundTasksConfig { pub watermark_update_ms: u64, } +#[GraphQLConfig] +#[derive(Clone)] +pub(crate) struct MoveRegistryConfig { + pub(crate) external_api_url: Option, + pub(crate) resolution_type: ResolutionType, + pub(crate) page_limit: u16, + pub(crate) package_address: SuiAddress, + pub(crate) registry_id: ObjectID, +} + +#[derive(Clone, Debug, Serialize, Deserialize, Eq, PartialEq)] +pub(crate) enum ResolutionType { + Internal, + External, +} + /// The Version of the service. `year.month` represents the major release. /// New `patch` versions represent backwards compatible fixes for their major release. /// The `full` version is `year.month.patch-sha`. @@ -360,6 +392,14 @@ impl ConnectionConfig { pub fn server_address(&self) -> String { format!("{}:{}", self.host, self.port) } + + pub fn host(&self) -> String { + self.host.clone() + } + + pub fn port(&self) -> u16 { + self.port + } } impl ServiceConfig { @@ -376,6 +416,29 @@ impl ServiceConfig { ..Default::default() } } + + pub fn dot_move_test_defaults( + external: bool, + endpoint: Option, + pkg_address: Option, + object_id: Option, + page_limit: Option, + ) -> Self { + Self { + move_registry: MoveRegistryConfig { + resolution_type: if external { + ResolutionType::External + } else { + ResolutionType::Internal + }, + external_api_url: endpoint, + package_address: pkg_address.unwrap_or_default(), + registry_id: object_id.unwrap_or(ObjectID::random()), + page_limit: page_limit.unwrap_or(50), + }, + ..Self::test_defaults() + } + } } impl Limits { @@ -406,6 +469,24 @@ impl BackgroundTasksConfig { } } +impl MoveRegistryConfig { + pub(crate) fn new( + resolution_type: ResolutionType, + external_api_url: Option, + page_limit: u16, + package_address: SuiAddress, + registry_id: ObjectID, + ) -> Self { + Self { + resolution_type, + external_api_url, + page_limit, + package_address, + registry_id, + } + } +} + impl Default for Versions { fn default() -> Self { Self { @@ -504,6 +585,20 @@ impl Display for Version { } } +// TODO: Keeping the values as is, because we'll remove the default getters +// when we refactor to use `[GraphqlConfig]` macro. +impl Default for MoveRegistryConfig { + fn default() -> Self { + Self::new( + ResolutionType::Internal, + None, + DEFAULT_PAGE_LIMIT, + SuiAddress::from_str(MOVE_REGISTRY_PACKAGE).unwrap(), + ObjectID::from_str(MOVE_REGISTRY_TABLE_ID).unwrap(), + ) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/sui-graphql-rpc/src/data.rs b/crates/sui-graphql-rpc/src/data.rs index f2a5bf554cc57..04e096061c116 100644 --- a/crates/sui-graphql-rpc/src/data.rs +++ b/crates/sui-graphql-rpc/src/data.rs @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 pub(crate) mod apys; +pub(crate) mod move_registry_data_loader; pub(crate) mod package_resolver; pub(crate) mod pg; diff --git a/crates/sui-graphql-rpc/src/data/move_registry_data_loader.rs b/crates/sui-graphql-rpc/src/data/move_registry_data_loader.rs new file mode 100644 index 0000000000000..239de68d4b169 --- /dev/null +++ b/crates/sui-graphql-rpc/src/data/move_registry_data_loader.rs @@ -0,0 +1,177 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 +use std::fmt::Write; + +use std::{collections::HashMap, str::FromStr, sync::Arc}; + +use async_graphql::dataloader::{DataLoader, Loader}; +use serde::{Deserialize, Serialize}; + +use crate::{ + config::MoveRegistryConfig, + error::Error, + types::{ + base64::Base64, + dot_move::{ + error::MoveRegistryError, + on_chain::{AppRecord, Name}, + }, + }, +}; + +/// GraphQL fragment to query the values of the dynamic fields. +const QUERY_FRAGMENT: &str = + "fragment RECORD_VALUES on DynamicField { value { ... on MoveValue { bcs } } }"; + +pub(crate) struct ExternalNamesLoader { + client: reqwest::Client, + config: MoveRegistryConfig, +} + +/// Helper types for accessing a shared `DataLoader` instance. +#[derive(Clone)] +pub(crate) struct MoveRegistryDataLoader(pub Arc>); + +impl ExternalNamesLoader { + pub(crate) fn new(config: MoveRegistryConfig) -> Self { + Self { + client: reqwest::Client::new(), + config, + } + } + + /// Constructs the GraphQL Query to query the names on an external graphql endpoint. + fn construct_names_graphql_query(&self, names: &[Name]) -> (String, HashMap) { + let mut mapping: HashMap = HashMap::new(); + + let mut result = format!(r#"{{ owner(address: "{}") {{"#, self.config.registry_id); + + // we create the GraphQL query keys with a `fetch_{id}` prefix, which is accepted on graphql fields. + for (index, name) in names.iter().enumerate() { + let bcs_base64 = name.to_base64_string(); + + // retain the mapping here (id to bcs representation, so we can pick the right response later on) + mapping.insert(name.clone(), index); + + // SAFETY: write! to String always succeeds + write!( + &mut result, + r#"{}: dynamicField(name: {{ type: "{}::name::Name", bcs: {} }}) {{ ...RECORD_VALUES }} "#, + fetch_key(&index), + self.config.package_address, + bcs_base64 + ).unwrap(); + } + + result.push_str("}} "); + result.push_str(QUERY_FRAGMENT); + + (result, mapping) + } +} + +impl MoveRegistryDataLoader { + pub(crate) fn new(config: MoveRegistryConfig) -> Self { + let batch_size = config.page_limit as usize; + let data_loader = DataLoader::new(ExternalNamesLoader::new(config), tokio::spawn) + .max_batch_size(batch_size); + Self(Arc::new(data_loader)) + } +} + +#[async_trait::async_trait] +impl Loader for ExternalNamesLoader { + type Value = AppRecord; + type Error = Error; + + /// This function queries the external API to fetch the app records for the requested names. + /// This is part of the data loader, so all queries are bulked-up to the maximum of {config.page_limit}. + /// We handle the cases where individual queries fail, to ensure that a failed query cannot affect + /// a successful one. + async fn load(&self, keys: &[Name]) -> Result, Error> { + let Some(api_url) = self.config.external_api_url.as_ref() else { + return Err(Error::MoveNameRegistry( + MoveRegistryError::ExternalApiUrlUnavailable, + )); + }; + + let (query, mapping) = self.construct_names_graphql_query(keys); + + let request_body = GraphQLRequest { + query, + variables: serde_json::Value::Null, + }; + + let res = self + .client + .post(api_url) + .json(&request_body) + .send() + .await + .map_err(|e| { + Error::MoveNameRegistry(MoveRegistryError::FailedToQueryExternalApi(e.to_string())) + })?; + + if !res.status().is_success() { + return Err(Error::MoveNameRegistry( + MoveRegistryError::FailedToQueryExternalApi(format!( + "Status code: {}", + res.status() + )), + )); + } + + let response_json: GraphQLResponse = res.json().await.map_err(|e| { + Error::MoveNameRegistry(MoveRegistryError::FailedToParseExternalResponse( + e.to_string(), + )) + })?; + + let names = response_json.data.owner.names; + + let results = HashMap::from_iter(mapping.into_iter().filter_map(|(k, idx)| { + let bcs = names.get(&fetch_key(&idx))?.as_ref()?; + let Base64(bytes) = Base64::from_str(&bcs.value.bcs).ok()?; + let app_record: AppRecord = bcs::from_bytes(&bytes).ok()?; + Some((k, app_record)) + })); + + Ok(results) + } +} + +fn fetch_key(idx: &usize) -> String { + format!("f_{}", idx) +} + +// GraphQL Request and Response types to deserialize for the data loader. +#[derive(Serialize)] +struct GraphQLRequest { + query: String, + variables: serde_json::Value, +} + +#[derive(Deserialize, Debug)] +struct GraphQLResponse { + data: T, +} +#[derive(Deserialize, Debug)] +struct Owner { + owner: Names, +} + +#[derive(Deserialize, Debug)] +struct Names { + #[serde(flatten)] + names: HashMap>, +} + +#[derive(Deserialize, Debug)] +struct OwnerValue { + value: NameBCS, +} + +#[derive(Deserialize, Debug)] +struct NameBCS { + bcs: String, +} diff --git a/crates/sui-graphql-rpc/src/error.rs b/crates/sui-graphql-rpc/src/error.rs index 8d9f633cbd3b5..eefe9b696b0a1 100644 --- a/crates/sui-graphql-rpc/src/error.rs +++ b/crates/sui-graphql-rpc/src/error.rs @@ -6,6 +6,8 @@ use async_graphql_axum::GraphQLResponse; use sui_indexer::errors::IndexerError; use sui_json_rpc::name_service::NameServiceError; +use crate::types::dot_move::error::MoveRegistryError; + /// Error codes for the `extensions.code` field of a GraphQL error that originates from outside /// GraphQL. /// `` @@ -76,12 +78,15 @@ pub enum Error { Client(String), #[error("Internal error occurred while processing request: {0}")] Internal(String), + #[error(transparent)] + MoveNameRegistry(#[from] MoveRegistryError), } impl ErrorExtensions for Error { fn extend(&self) -> async_graphql::Error { async_graphql::Error::new(format!("{}", self)).extend_with(|_err, e| match self { Error::NameService(_) + | Error::MoveNameRegistry(_) | Error::CursorNoFirstLast | Error::PageTooLarge(_, _) | Error::ProtocolVersionUnsupported(_, _) diff --git a/crates/sui-graphql-rpc/src/functional_group.rs b/crates/sui-graphql-rpc/src/functional_group.rs index 538b75d376dc2..6b4642b8f0f68 100644 --- a/crates/sui-graphql-rpc/src/functional_group.rs +++ b/crates/sui-graphql-rpc/src/functional_group.rs @@ -32,6 +32,9 @@ pub(crate) enum FunctionalGroup { /// Aspects that affect the running of the system that are managed by the /// validators either directly, or through system transactions. SystemState, + + /// Named packages service (utilizing dotmove package registry). + MoveRegistry, } impl FunctionalGroup { @@ -51,6 +54,7 @@ impl FunctionalGroup { G::NameService, G::Subscriptions, G::SystemState, + G::MoveRegistry, ]; ALL } @@ -96,6 +100,8 @@ fn functional_groups() -> &'static BTreeMap<(&'static str, &'static str), Functi (("Query", "networkMetrics"), G::Analytics), (("Query", "protocolConfig"), G::SystemState), (("Query", "resolveSuinsAddress"), G::NameService), + (("Query", "packageByName"), G::MoveRegistry), + (("Query", "typeByName"), G::MoveRegistry), (("Subscription", "events"), G::Subscriptions), (("Subscription", "transactions"), G::Subscriptions), (("SystemStateSummary", "safeMode"), G::SystemState), diff --git a/crates/sui-graphql-rpc/src/server/builder.rs b/crates/sui-graphql-rpc/src/server/builder.rs index b1a9d5931e6cd..e29f48569dcc3 100644 --- a/crates/sui-graphql-rpc/src/server/builder.rs +++ b/crates/sui-graphql-rpc/src/server/builder.rs @@ -9,6 +9,7 @@ use crate::config::{ ConnectionConfig, ServiceConfig, Version, MAX_CONCURRENT_REQUESTS, RPC_TIMEOUT_ERR_SLEEP_RETRY_PERIOD, }; +use crate::data::move_registry_data_loader::MoveRegistryDataLoader; use crate::data::package_resolver::{DbPackageStore, PackageResolver}; use crate::data::{DataLoader, Db}; use crate::extensions::directive_checker::DirectiveChecker; @@ -411,6 +412,7 @@ impl ServerBuilder { let mut builder = ServerBuilder::new(state); let name_service_config = config.service.name_service.clone(); + let move_registry_config = config.service.move_registry.clone(); let zklogin_config = config.service.zklogin.clone(); let reader = PgManager::reader_with_config( config.connection.db_url.clone(), @@ -465,7 +467,9 @@ impl ServerBuilder { .context_data(name_service_config) .context_data(zklogin_config) .context_data(metrics.clone()) - .context_data(config.clone()); + .context_data(config.clone()) + .context_data(move_registry_config.clone()) + .context_data(MoveRegistryDataLoader::new(move_registry_config)); if config.internal_features.feature_gate { builder = builder.extension(FeatureGate); diff --git a/crates/sui-graphql-rpc/src/test_infra/cluster.rs b/crates/sui-graphql-rpc/src/test_infra/cluster.rs index 8b5967fc11cce..f0f9a686c88d6 100644 --- a/crates/sui-graphql-rpc/src/test_infra/cluster.rs +++ b/crates/sui-graphql-rpc/src/test_infra/cluster.rs @@ -47,11 +47,15 @@ pub struct ExecutorCluster { } pub struct Cluster { + pub network: NetworkCluster, + pub graphql_server_join_handle: JoinHandle<()>, + pub graphql_client: SimpleClient, +} + +pub struct NetworkCluster { pub validator_fullnode_handle: TestCluster, pub indexer_store: PgIndexerStore, pub indexer_join_handle: JoinHandle>, - pub graphql_server_join_handle: JoinHandle<()>, - pub graphql_client: SimpleClient, pub cancellation_token: CancellationToken, } @@ -59,32 +63,24 @@ pub struct Cluster { pub async fn start_cluster( graphql_connection_config: ConnectionConfig, internal_data_source_rpc_port: Option, + service_config: ServiceConfig, ) -> Cluster { - let data_ingestion_path = tempfile::tempdir().unwrap().into_path(); - let db_url = graphql_connection_config.db_url.clone(); - let cancellation_token = CancellationToken::new(); - // Starts validator+fullnode - let val_fn = - start_validator_with_fullnode(internal_data_source_rpc_port, data_ingestion_path.clone()) - .await; - - // Starts indexer - let (pg_store, pg_handle) = start_test_indexer_impl( - Some(db_url), - val_fn.rpc_url().to_string(), - ReaderWriterConfig::writer_mode(None), - /* reset_database */ true, - Some(data_ingestion_path), - cancellation_token.clone(), + let network_cluster = start_network_cluster( + graphql_connection_config.clone(), + internal_data_source_rpc_port, ) .await; - // Starts graphql server - let fn_rpc_url = val_fn.rpc_url().to_string(); + let fn_rpc_url: String = network_cluster + .validator_fullnode_handle + .rpc_url() + .to_string(); + let graphql_server_handle = start_graphql_server_with_fn_rpc( graphql_connection_config.clone(), Some(fn_rpc_url), - Some(cancellation_token.clone()), + Some(network_cluster.cancellation_token.clone()), + service_config, ) .await; @@ -98,11 +94,43 @@ pub async fn start_cluster( wait_for_graphql_server(&client).await; Cluster { + network: network_cluster, + graphql_server_join_handle: graphql_server_handle, + graphql_client: client, + } +} + +/// Starts a validator, fullnode, indexer (using data ingestion). Re-using GraphQL's ConnectionConfig for convenience. +/// This does not start any GraphQL services, only the network cluster. You can start a GraphQL service +/// calling `start_graphql_server`. +pub async fn start_network_cluster( + graphql_connection_config: ConnectionConfig, + internal_data_source_rpc_port: Option, +) -> NetworkCluster { + let data_ingestion_path = tempfile::tempdir().unwrap().into_path(); + let db_url = graphql_connection_config.db_url.clone(); + let cancellation_token = CancellationToken::new(); + + // Starts validator+fullnode + let val_fn = + start_validator_with_fullnode(internal_data_source_rpc_port, data_ingestion_path.clone()) + .await; + + // Starts indexer + let (pg_store, pg_handle) = start_test_indexer_impl( + Some(db_url), + val_fn.rpc_url().to_string(), + ReaderWriterConfig::writer_mode(None), + /* reset_database */ true, + Some(data_ingestion_path), + cancellation_token.clone(), + ) + .await; + + NetworkCluster { validator_fullnode_handle: val_fn, indexer_store: pg_store, indexer_join_handle: pg_handle, - graphql_server_join_handle: graphql_server_handle, - graphql_client: client, cancellation_token, } } @@ -145,6 +173,7 @@ pub async fn serve_executor( let graphql_server_handle = start_graphql_server( graphql_connection_config.clone(), cancellation_token.clone(), + ServiceConfig::test_defaults(), ) .await; @@ -172,20 +201,27 @@ pub async fn serve_executor( pub async fn start_graphql_server( graphql_connection_config: ConnectionConfig, cancellation_token: CancellationToken, + service_config: ServiceConfig, ) -> JoinHandle<()> { - start_graphql_server_with_fn_rpc(graphql_connection_config, None, Some(cancellation_token)) - .await + start_graphql_server_with_fn_rpc( + graphql_connection_config, + None, + Some(cancellation_token), + service_config, + ) + .await } pub async fn start_graphql_server_with_fn_rpc( graphql_connection_config: ConnectionConfig, fn_rpc_url: Option, cancellation_token: Option, + service_config: ServiceConfig, ) -> JoinHandle<()> { let cancellation_token = cancellation_token.unwrap_or_default(); let mut server_config = ServerConfig { connection: graphql_connection_config, - service: ServiceConfig::test_defaults(), + service: service_config, ..ServerConfig::default() }; if let Some(fn_rpc_url) = fn_rpc_url { @@ -224,7 +260,7 @@ async fn start_validator_with_fullnode( } /// Repeatedly ping the GraphQL server for 10s, until it responds -async fn wait_for_graphql_server(client: &SimpleClient) { +pub async fn wait_for_graphql_server(client: &SimpleClient) { tokio::time::timeout(Duration::from_secs(10), async { while client.ping().await.is_err() { tokio::time::sleep(Duration::from_millis(500)).await; @@ -236,7 +272,7 @@ async fn wait_for_graphql_server(client: &SimpleClient) { /// Ping the GraphQL server until its background task has updated the checkpoint watermark to the /// desired checkpoint. -async fn wait_for_graphql_checkpoint_catchup( +pub async fn wait_for_graphql_checkpoint_catchup( client: &SimpleClient, checkpoint: u64, base_timeout: Duration, @@ -295,9 +331,16 @@ impl Cluster { /// Sends a cancellation signal to the graphql and indexer services and waits for them to /// shutdown. + pub async fn cleanup_resources(self) { + self.network.cleanup_resources().await; + let _ = self.graphql_server_join_handle.await; + } +} + +impl NetworkCluster { pub async fn cleanup_resources(self) { self.cancellation_token.cancel(); - let _ = join!(self.graphql_server_join_handle, self.indexer_join_handle); + let _ = self.indexer_join_handle.await; } } diff --git a/crates/sui-graphql-rpc/src/types/dot_move/error.rs b/crates/sui-graphql-rpc/src/types/dot_move/error.rs new file mode 100644 index 0000000000000..f2d45927074d8 --- /dev/null +++ b/crates/sui-graphql-rpc/src/types/dot_move/error.rs @@ -0,0 +1,35 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +use serde::{Deserialize, Serialize}; +use sui_types::base_types::ObjectID; + +#[derive(thiserror::Error, Debug, Serialize, Deserialize, Clone, Eq, PartialEq)] +pub enum MoveRegistryError { + // The chain identifier is not available, so we cannot determine where to look for the name. + #[error("Move Registry: Cannot determine which chain to query due to an internal error.")] + ChainIdentifierUnavailable, + // The name was found in the service, but it is not a valid name. + #[error("Move Registry: The request name {0} is malformed.")] + InvalidName(String), + + #[error("Move Registry: External API url is not available so resolution is not on this RPC.")] + ExternalApiUrlUnavailable, + + #[error( + "Move Registry: Internal Error, failed to query external API due to an internal error: {0}" + )] + FailedToQueryExternalApi(String), + + #[error("Move Registry Internal Error: Failed to parse external API's response: {0}")] + FailedToParseExternalResponse(String), + + #[error("Move Registry Internal Error: Failed to deserialize record ${0}.")] + FailedToDeserializeRecord(ObjectID), + + #[error("Move Registry: The name {0} was not found.")] + NameNotFound(String), + + #[error("Move Registry: Invalid version")] + InvalidVersion, +} diff --git a/crates/sui-graphql-rpc/src/types/dot_move/mod.rs b/crates/sui-graphql-rpc/src/types/dot_move/mod.rs new file mode 100644 index 0000000000000..b988bd638e246 --- /dev/null +++ b/crates/sui-graphql-rpc/src/types/dot_move/mod.rs @@ -0,0 +1,7 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +pub(crate) mod error; +pub(crate) mod named_move_package; +pub(crate) mod named_type; +pub(crate) mod on_chain; diff --git a/crates/sui-graphql-rpc/src/types/dot_move/named_move_package.rs b/crates/sui-graphql-rpc/src/types/dot_move/named_move_package.rs new file mode 100644 index 0000000000000..959e736ba70f4 --- /dev/null +++ b/crates/sui-graphql-rpc/src/types/dot_move/named_move_package.rs @@ -0,0 +1,126 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +use std::str::FromStr; + +use async_graphql::Context; + +use crate::{ + config::{MoveRegistryConfig, ResolutionType}, + data::move_registry_data_loader::MoveRegistryDataLoader, + error::Error, + types::{ + chain_identifier::ChainIdentifier, move_object::MoveObject, move_package::MovePackage, + object::Object, + }, +}; + +use super::{ + error::MoveRegistryError, + on_chain::{AppInfo, AppRecord, VersionedName}, +}; + +pub(crate) struct NamedMovePackage; + +impl NamedMovePackage { + /// Queries a package by name (and version, encoded in the name). + /// Name's format should be `{application}@{organization}/v{versiion}`. + pub(crate) async fn query( + ctx: &Context<'_>, + name: &str, + checkpoint_viewed_at: u64, + ) -> Result, Error> { + let config: &MoveRegistryConfig = ctx.data_unchecked(); + let versioned = VersionedName::from_str(name)?; + + match config.resolution_type { + ResolutionType::Internal => { + query_internal(ctx, config, versioned, checkpoint_viewed_at).await + } + ResolutionType::External => { + query_external(ctx, config, versioned, checkpoint_viewed_at).await + } + } + } +} + +async fn query_internal( + ctx: &Context<'_>, + config: &MoveRegistryConfig, + versioned: VersionedName, + checkpoint_viewed_at: u64, +) -> Result, Error> { + let df_id = versioned.name.to_dynamic_field_id(config).map_err(|e| { + Error::Internal(format!("Failed to convert name to dynamic field id: {}", e)) + })?; + + let Some(df) = + MoveObject::query(ctx, df_id.into(), Object::latest_at(checkpoint_viewed_at)).await? + else { + return Ok(None); + }; + + let app_record = AppRecord::try_from(df.native)?; + + let Some(app_info) = app_record.app_info else { + return Ok(None); + }; + + package_from_app_info(ctx, app_info, versioned.version, checkpoint_viewed_at).await +} + +async fn query_external( + ctx: &Context<'_>, + config: &MoveRegistryConfig, + versioned: VersionedName, + checkpoint_viewed_at: u64, +) -> Result, Error> { + if config.external_api_url.is_none() { + return Err(MoveRegistryError::ExternalApiUrlUnavailable.into()); + } + + let ChainIdentifier(Some(chain_id)) = ctx.data_unchecked() else { + return Err(MoveRegistryError::ChainIdentifierUnavailable.into()); + }; + + let MoveRegistryDataLoader(loader) = ctx.data_unchecked(); + + let Some(result) = loader.load_one(versioned.name).await? else { + return Ok(None); + }; + + let Some(app_info) = result.networks.get(&chain_id.to_string()) else { + return Ok(None); + }; + + package_from_app_info( + ctx, + app_info.clone(), + versioned.version, + checkpoint_viewed_at, + ) + .await +} + +async fn package_from_app_info( + ctx: &Context<'_>, + app_info: AppInfo, + version: Option, + checkpoint_viewed_at: u64, +) -> Result, Error> { + let Some(package_address) = app_info.package_address else { + return Ok(None); + }; + + // let's now find the package at a specified version (or latest) + MovePackage::query( + ctx, + package_address.into(), + if let Some(v) = version { + MovePackage::by_version(v, checkpoint_viewed_at) + } else { + MovePackage::latest_at(checkpoint_viewed_at) + }, + ) + .await +} diff --git a/crates/sui-graphql-rpc/src/types/dot_move/named_type.rs b/crates/sui-graphql-rpc/src/types/dot_move/named_type.rs new file mode 100644 index 0000000000000..1b6a7ee226ee5 --- /dev/null +++ b/crates/sui-graphql-rpc/src/types/dot_move/named_type.rs @@ -0,0 +1,231 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::HashMap; +use std::str::FromStr; + +use async_graphql::Context; +use futures::future; +use regex::{Captures, Regex}; +use sui_types::{base_types::ObjectID, TypeTag}; + +use crate::error::Error; + +use super::{ + error::MoveRegistryError, named_move_package::NamedMovePackage, + on_chain::VERSIONED_NAME_UNBOUND_REG, +}; + +pub(crate) struct NamedType; + +impl NamedType { + /// Queries a type by the given name. + /// Name should be a valid type tag, with move names in it in the format `app@org::type::Type`. + /// For nested type params, we just follow the same pattern e.g. `app@org::type::Type`. + pub(crate) async fn query( + ctx: &Context<'_>, + name: &str, + checkpoint_viewed_at: u64, + ) -> Result { + // we do not de-duplicate the names here, as the dataloader will do this for us. + let names = Self::parse_names(name)?; + + // Gather all the requests to resolve the names. + let names_to_resolve = names + .iter() + .map(|x| NamedMovePackage::query(ctx, x, checkpoint_viewed_at)) + .collect::>(); + + // now we resolve all the names in parallel (data-loader will do the proper de-duplication / batching for us) + // also the `NamedMovePackage` query will re-validate the names (including max length, which is not checked on the regex). + let results = future::try_join_all(names_to_resolve).await?; + + // now let's create a hashmap with {name: MovePackage} + let mut name_package_id_mapping = HashMap::new(); + + for (name, result) in names.into_iter().zip(results.into_iter()) { + let Some(package) = result else { + return Err(Error::MoveNameRegistry(MoveRegistryError::NameNotFound( + name, + ))); + }; + name_package_id_mapping.insert(name, package.native.id()); + } + + let correct_type_tag: String = Self::replace_names(name, &name_package_id_mapping)?; + + TypeTag::from_str(&correct_type_tag).map_err(|e| Error::Client(format!("bad type: {e}"))) + } + + /// Is this already caught by the global limits? + /// This parser just extracts all names from a type tag, and returns them + /// We do not care about de-duplication, as the dataloader will do this for us. + /// The goal of replacing all of them with `0x0` is to make sure that the type tag is valid + /// so when replaced with the move name package addresses, it'll also be valid. + fn parse_names(name: &str) -> Result, Error> { + let mut names = vec![]; + let struct_tag = VERSIONED_NAME_UNBOUND_REG.replace_all(name, |m: ®ex::Captures| { + // SAFETY: we know that the regex will always have a match on position 0. + names.push(m.get(0).unwrap().as_str().to_string()); + "0x0".to_string() + }); + + // We attempt to parse the type_tag with these replacements, to make sure there are no other + // errors in the type tag (apart from the move names). That protects us from unnecessary + // queries to resolve .move names, for a type tag that will be invalid anyway. + TypeTag::from_str(&struct_tag).map_err(|e| Error::Client(format!("bad type: {e}")))?; + + Ok(names) + } + + /// This function replaces all the names in the type tag with their corresponding MovePackage address. + /// The names are guaranteed to be the same and exist (as long as this is called in sequence), + /// since we use the same parser to extract the names. + fn replace_names(type_name: &str, names: &HashMap) -> Result { + let struct_tag_str = replace_all_result( + &VERSIONED_NAME_UNBOUND_REG, + type_name, + |m: ®ex::Captures| { + // SAFETY: we know that the regex will have a match on position 0. + let name = m.get(0).unwrap().as_str(); + + // if we are misusing the function, and we cannot find the name in the hashmap, + // we return an empty string, which will make the type tag invalid. + if let Some(addr) = names.get(name) { + Ok(addr.to_string()) + } else { + Err(Error::MoveNameRegistry(MoveRegistryError::NameNotFound( + name.to_string(), + ))) + } + }, + )?; + + Ok(struct_tag_str.to_string()) + } +} + +/// Helper to replace all occurrences of a regex with a function that returns a string. +/// Used as a replacement of `regex`.replace_all(). +/// The only difference is that this function returns a Result, so we can handle errors. +fn replace_all_result( + re: &Regex, + haystack: &str, + replacement: impl Fn(&Captures) -> Result, +) -> Result { + let mut new = String::with_capacity(haystack.len()); + let mut last_match = 0; + for caps in re.captures_iter(haystack) { + // SAFETY: we know that the regex will have a match on position 0. + let m = caps.get(0).unwrap(); + new.push_str(&haystack[last_match..m.start()]); + new.push_str(&replacement(&caps)?); + last_match = m.end(); + } + new.push_str(&haystack[last_match..]); + Ok(new) +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use sui_types::base_types::ObjectID; + + use super::NamedType; + + struct DemoData { + input_type: String, + expected_output: String, + expected_names: Vec, + } + + #[test] + fn parse_and_replace_type_successfully() { + let mut demo_data = vec![]; + + demo_data.push(DemoData { + input_type: "app@org::type::Type".to_string(), + expected_output: format_type("0x0", "::type::Type"), + expected_names: vec!["app@org".to_string()], + }); + + demo_data.push(DemoData { + input_type: "0xapp@org::type::Type".to_string(), + expected_output: format_type("0x0", "::type::Type"), + expected_names: vec!["0xapp@org".to_string()], + }); + + demo_data.push(DemoData { + input_type: "app@org::type::Type".to_string(), + expected_output: format!("{}", format_type("0x0", "::type::Type")), + expected_names: vec!["app@org".to_string()], + }); + + demo_data.push(DemoData { + input_type: "app@org::type::Type".to_string(), + expected_output: format!( + "{}<{}, u64>", + format_type("0x0", "::type::Type"), + format_type("0x1", "::type::AnotherType") + ), + expected_names: vec!["app@org".to_string(), "another-app@org".to_string()], + }); + + demo_data.push(DemoData { + input_type: "app@org::type::Type, 0x1::string::String>".to_string(), + expected_output: format!("{}<{}<{}>, 0x1::string::String>", format_type("0x0", "::type::Type"), format_type("0x1", "::type::AnotherType"), format_type("0x2", "::inner::Type")), + expected_names: vec![ + "app@org".to_string(), + "another-app@org".to_string(), + "even-more-nested@org".to_string(), + ], + }); + + for data in demo_data { + let names = NamedType::parse_names(&data.input_type).unwrap(); + assert_eq!(names, data.expected_names); + + let mut mapping = HashMap::new(); + + for (index, name) in data.expected_names.iter().enumerate() { + mapping.insert( + name.clone(), + ObjectID::from_hex_literal(&format!("0x{}", index)).unwrap(), + ); + } + + let replaced = NamedType::replace_names(&data.input_type, &mapping); + assert_eq!(replaced.unwrap(), data.expected_output); + } + } + + #[test] + fn parse_and_replace_type_errors() { + let types = vec![ + "--app@org::type::Type", + "app@org::type::Type<", + "app@org::type::Type", + "", + ]; + + // TODO: add snapshot tests for predictable errors. + for t in types { + assert!(NamedType::parse_names(t).is_err()); + } + } + + fn format_type(address: &str, rest: &str) -> String { + format!( + "{}{}", + ObjectID::from_hex_literal(address) + .unwrap() + .to_canonical_string(true), + rest + ) + } +} diff --git a/crates/sui-graphql-rpc/src/types/dot_move/on_chain.rs b/crates/sui-graphql-rpc/src/types/dot_move/on_chain.rs new file mode 100644 index 0000000000000..52940c7594775 --- /dev/null +++ b/crates/sui-graphql-rpc/src/types/dot_move/on_chain.rs @@ -0,0 +1,290 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +use std::str::FromStr; + +use async_graphql::ScalarType; +use move_core_types::language_storage::StructTag; +use once_cell::sync::Lazy; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use sui_types::{ + base_types::{ObjectID, SuiAddress}, + collection_types::VecMap, + dynamic_field::Field, + id::ID, + object::MoveObject as NativeMoveObject, +}; + +use crate::{ + config::{MoveRegistryConfig, MOVE_REGISTRY_MODULE, MOVE_REGISTRY_TYPE}, + types::base64::Base64, +}; + +use super::error::MoveRegistryError; + +const MAX_LABEL_LENGTH: usize = 63; + +/// Regex to parse a dot move name. Version is optional (defaults to latest). +/// For versioned format, the expected format is `app@org/v1`. +/// For an unversioned format, the expected format is `app@org`. +/// +/// The unbound regex can be used to search matches in a type tag. +/// Use `VERSIONED_NAME_REGEX` for parsing a single name from a str. +const VERSIONED_NAME_UNBOUND_REGEX: &str = concat!( + "([a-z0-9]+(?:-[a-z0-9]+)*)", + "@", + "([a-z0-9]+(?:-[a-z0-9]+)*)", + r"(?:\/v(\d+))?" +); + +/// Regex to parse a dot move name. Version is optional (defaults to latest). +/// For versioned format, the expected format is `app@org/v1`. +/// For an unversioned format, the expected format is `app@org`. +/// +/// This regex is used to parse a single name (does not do type_tag matching). +/// Use `VERSIONED_NAME_UNBOUND_REGEX` for type tag matching. +const VERSIONED_NAME_REGEX: &str = concat!( + "^", + "([a-z0-9]+(?:-[a-z0-9]+)*)", + "@", + "([a-z0-9]+(?:-[a-z0-9]+)*)", + r"(?:\/v(\d+))?", + "$" +); + +/// A regular expression that detects all possible dot move names in a type tag. +pub(crate) static VERSIONED_NAME_UNBOUND_REG: Lazy = + Lazy::new(|| Regex::new(VERSIONED_NAME_UNBOUND_REGEX).unwrap()); + +/// A regular expression that detects a single name in the format `app@org/v1`. +pub(crate) static VERSIONED_NAME_REG: Lazy = + Lazy::new(|| Regex::new(VERSIONED_NAME_REGEX).unwrap()); + +/// An AppRecord entry in the DotMove service. +/// Attention: The format of this struct should not change unless the on-chain format changes, +/// as we define it to deserialize on-chain data. +#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq)] +pub(crate) struct AppRecord { + pub(crate) app_cap_id: ID, + pub(crate) app_info: Option, + pub(crate) networks: VecMap, + pub(crate) metadata: VecMap, + pub(crate) storage: ObjectID, +} + +/// Attention: The format of this struct should not change unless the on-chain format changes, +/// as we define it to deserialize on-chain data. +#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq)] +pub(crate) struct AppInfo { + pub(crate) package_info_id: Option, + pub(crate) package_address: Option, + pub(crate) upgrade_cap_id: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq)] +pub(crate) struct VersionedName { + /// A version name defaults at None, which means we need the latest version. + pub(crate) version: Option, + /// The on-chain `Name` object that represents the dot_move name. + pub(crate) name: Name, +} + +/// Attention: The format of this struct should not change unless the on-chain format changes, +/// as we define it to deserialize on-chain data. +#[derive(Debug, Serialize, Deserialize, Hash, Clone, Eq, PartialEq)] +pub(crate) struct Name { + pub(crate) labels: Vec, + pub(crate) normalized: String, +} + +impl Name { + pub(crate) fn new(app_name: &str, org_name: &str) -> Self { + let normalized = format!("{}@{}", app_name, org_name); + let labels = vec![org_name.to_string(), app_name.to_string()]; + Self { labels, normalized } + } + + pub(crate) fn type_(package_address: SuiAddress) -> StructTag { + StructTag { + address: package_address.into(), + module: MOVE_REGISTRY_MODULE.to_owned(), + name: MOVE_REGISTRY_TYPE.to_owned(), + type_params: vec![], + } + } + + pub(crate) fn to_bytes(&self) -> Vec { + bcs::to_bytes(&self).unwrap() + } + + pub(crate) fn to_base64_string(&self) -> String { + Base64::from(self.to_bytes()).to_value().to_string() + } + + /// Generate the ObjectID for a given `Name` + pub(crate) fn to_dynamic_field_id( + &self, + config: &MoveRegistryConfig, + ) -> Result { + let domain_type_tag = Self::type_(config.package_address); + + sui_types::dynamic_field::derive_dynamic_field_id( + config.registry_id, + &sui_types::TypeTag::Struct(Box::new(domain_type_tag)), + &self.to_bytes(), + ) + } +} + +impl FromStr for VersionedName { + type Err = MoveRegistryError; + + fn from_str(s: &str) -> Result { + let Some(caps) = VERSIONED_NAME_REG.captures(s) else { + return Err(MoveRegistryError::InvalidName(s.to_string())); + }; + + let Some(app_name) = caps.get(1).map(|x| x.as_str()) else { + return Err(MoveRegistryError::InvalidName(s.to_string())); + }; + + let Some(org_name) = caps.get(2).map(|x| x.as_str()) else { + return Err(MoveRegistryError::InvalidName(s.to_string())); + }; + + if (org_name.len() > MAX_LABEL_LENGTH) || (app_name.len() > MAX_LABEL_LENGTH) { + return Err(MoveRegistryError::InvalidName(s.to_string())); + }; + + let version: Option = caps + .get(3) + .map(|x| x.as_str().parse()) + .transpose() + .map_err(|_| MoveRegistryError::InvalidVersion)?; + + Ok(Self { + version, + name: Name::new(app_name, org_name), + }) + } +} + +impl TryFrom for AppRecord { + type Error = MoveRegistryError; + + fn try_from(object: NativeMoveObject) -> Result { + object + .to_rust::>() + .map(|record| record.value) + .ok_or_else(|| MoveRegistryError::FailedToDeserializeRecord(object.id())) + } +} + +#[cfg(test)] +mod tests { + use super::VersionedName; + use std::str::FromStr; + + #[test] + fn parse_some_names() { + let versioned = VersionedName::from_str("app@org/v1").unwrap(); + assert_eq!(versioned.name.normalized, "app@org"); + assert!(versioned.version.is_some_and(|x| x == 1)); + + assert!(VersionedName::from_str("app@org/v34") + .unwrap() + .version + .is_some_and(|x| x == 34)); + assert!(VersionedName::from_str("app@org") + .unwrap() + .version + .is_none()); + + let ok_names = vec!["1-app@org/v1", "1-app@org/v34", "1-app@org"]; + + let composite_ok_names = vec![ + format!("{}@org/v1", generate_fixed_string(63)), + format!("{}-app@org/v34", generate_fixed_string(59)), + format!( + "{}@{}", + generate_fixed_string(63), + generate_fixed_string(63) + ), + format!( + "{}@{}-{}", + generate_fixed_string(63), + generate_fixed_string(30), + generate_fixed_string(30) + ), + ]; + + for name in ok_names { + assert!(VersionedName::from_str(name).is_ok()); + } + for name in composite_ok_names { + assert!(VersionedName::from_str(&name).is_ok()); + } + + let not_ok_names = vec![ + "-app@org", + "1.app@org", + "1--app@org", + "app-@org", + "app--@org", + "app@org/", + "app@org/v", + "app@org/veh", + "@org", + "app@/veh", + "app", + "@", + "ap@@org", + "ap!org", + "ap#org", + "ap#org@org", + "app%org", + "", + " ", + ]; + let composite_err_names = vec![ + format!( + "{}--{}@{}", + generate_fixed_string(10), + generate_fixed_string(10), + generate_fixed_string(63) + ), + format!( + "--{}-{}@{}", + generate_fixed_string(10), + generate_fixed_string(10), + generate_fixed_string(63) + ), + format!( + "{}@{}--{}", + generate_fixed_string(63), + generate_fixed_string(30), + generate_fixed_string(30) + ), + ]; + + for name in not_ok_names { + assert!(VersionedName::from_str(name).is_err()); + } + for name in composite_err_names { + assert!(VersionedName::from_str(&name).is_err()); + } + } + + fn generate_fixed_string(len: usize) -> String { + // Define the characters to use in the string + let chars = "abcdefghijklmnopqrstuvwxyz0123456789"; + // Repeat the characters to ensure we have at least 63 characters + let repeated_chars = chars.repeat(3); + + // Take the first 63 characters to form the string + let fixed_string = &repeated_chars[0..len]; + + fixed_string.to_string() + } +} diff --git a/crates/sui-graphql-rpc/src/types/mod.rs b/crates/sui-graphql-rpc/src/types/mod.rs index e944f0d33ebce..a20e0399aa5c0 100644 --- a/crates/sui-graphql-rpc/src/types/mod.rs +++ b/crates/sui-graphql-rpc/src/types/mod.rs @@ -16,6 +16,7 @@ pub(crate) mod datatype; pub(crate) mod date_time; pub(crate) mod digest; pub(crate) mod display; +pub(crate) mod dot_move; pub(crate) mod dry_run_result; pub(crate) mod dynamic_field; pub(crate) mod epoch; diff --git a/crates/sui-graphql-rpc/src/types/query.rs b/crates/sui-graphql-rpc/src/types/query.rs index ccc0c6b260a43..598c6db168699 100644 --- a/crates/sui-graphql-rpc/src/types/query.rs +++ b/crates/sui-graphql-rpc/src/types/query.rs @@ -12,6 +12,8 @@ use sui_sdk::SuiClient; use sui_types::transaction::{TransactionData, TransactionKind}; use sui_types::{gas_coin::GAS, transaction::TransactionDataAPI, TypeTag}; +use super::dot_move::named_move_package::NamedMovePackage; +use super::dot_move::named_type::NamedType; use super::move_package::{ self, MovePackage, MovePackageCheckpointFilter, MovePackageVersionFilter, }; @@ -542,6 +544,27 @@ impl Query { })) } + /// Fetch a package by its name (using dot move service) + async fn package_by_name( + &self, + ctx: &Context<'_>, + name: String, + ) -> Result> { + let Watermark { checkpoint, .. } = *ctx.data()?; + + NamedMovePackage::query(ctx, &name, checkpoint) + .await + .extend() + } + + /// Fetch a type that includes dot move service names in it. + async fn type_by_name(&self, ctx: &Context<'_>, name: String) -> Result { + let Watermark { checkpoint, .. } = *ctx.data()?; + let type_tag = NamedType::query(ctx, &name, checkpoint).await?; + + Ok(MoveType::new(type_tag)) + } + /// The coin metadata associated with the given coin type. async fn coin_metadata( &self, diff --git a/crates/sui-graphql-rpc/tests/dot_move/demo/Move.toml b/crates/sui-graphql-rpc/tests/dot_move/demo/Move.toml new file mode 100644 index 0000000000000..b052b21afe6af --- /dev/null +++ b/crates/sui-graphql-rpc/tests/dot_move/demo/Move.toml @@ -0,0 +1,10 @@ +[package] +name = "demo" +edition = "2024.beta" + +[dependencies] +Sui = { local = "../../../../sui-framework/packages/sui-framework" } + +[addresses] +demo = "0x0" + diff --git a/crates/sui-graphql-rpc/tests/dot_move/demo/sources/demo.move b/crates/sui-graphql-rpc/tests/dot_move/demo/sources/demo.move new file mode 100644 index 0000000000000..20a83730b2b8b --- /dev/null +++ b/crates/sui-graphql-rpc/tests/dot_move/demo/sources/demo.move @@ -0,0 +1,7 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +module demo::demo { + public struct V1Type has drop {} + public fun noop() {} +} diff --git a/crates/sui-graphql-rpc/tests/dot_move/demo_v2/Move.toml b/crates/sui-graphql-rpc/tests/dot_move/demo_v2/Move.toml new file mode 100644 index 0000000000000..b052b21afe6af --- /dev/null +++ b/crates/sui-graphql-rpc/tests/dot_move/demo_v2/Move.toml @@ -0,0 +1,10 @@ +[package] +name = "demo" +edition = "2024.beta" + +[dependencies] +Sui = { local = "../../../../sui-framework/packages/sui-framework" } + +[addresses] +demo = "0x0" + diff --git a/crates/sui-graphql-rpc/tests/dot_move/demo_v2/sources/demo.move b/crates/sui-graphql-rpc/tests/dot_move/demo_v2/sources/demo.move new file mode 100644 index 0000000000000..d810c780f5ea7 --- /dev/null +++ b/crates/sui-graphql-rpc/tests/dot_move/demo_v2/sources/demo.move @@ -0,0 +1,8 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +module demo::demo { + public struct V1Type has drop {} + public struct V2Type has drop {} + public fun noop() {} +} diff --git a/crates/sui-graphql-rpc/tests/dot_move/demo_v3/Move.toml b/crates/sui-graphql-rpc/tests/dot_move/demo_v3/Move.toml new file mode 100644 index 0000000000000..b052b21afe6af --- /dev/null +++ b/crates/sui-graphql-rpc/tests/dot_move/demo_v3/Move.toml @@ -0,0 +1,10 @@ +[package] +name = "demo" +edition = "2024.beta" + +[dependencies] +Sui = { local = "../../../../sui-framework/packages/sui-framework" } + +[addresses] +demo = "0x0" + diff --git a/crates/sui-graphql-rpc/tests/dot_move/demo_v3/sources/demo.move b/crates/sui-graphql-rpc/tests/dot_move/demo_v3/sources/demo.move new file mode 100644 index 0000000000000..abf73dffc0dc2 --- /dev/null +++ b/crates/sui-graphql-rpc/tests/dot_move/demo_v3/sources/demo.move @@ -0,0 +1,9 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +module demo::demo { + public struct V1Type has drop {} + public struct V2Type has drop {} + public struct V3Type has drop {} + public fun noop() {} +} diff --git a/crates/sui-graphql-rpc/tests/dot_move/dot_move/Move.toml b/crates/sui-graphql-rpc/tests/dot_move/dot_move/Move.toml new file mode 100644 index 0000000000000..3fa8fcb1c2dae --- /dev/null +++ b/crates/sui-graphql-rpc/tests/dot_move/dot_move/Move.toml @@ -0,0 +1,10 @@ +[package] +name = "dotmove" +edition = "2024.beta" + +[dependencies] +Sui = { local = "../../../../sui-framework/packages/sui-framework" } + +[addresses] +dotmove = "0x0" + diff --git a/crates/sui-graphql-rpc/tests/dot_move/dot_move/sources/dotmove.move b/crates/sui-graphql-rpc/tests/dot_move/dot_move/sources/dotmove.move new file mode 100644 index 0000000000000..9a05df46eb705 --- /dev/null +++ b/crates/sui-graphql-rpc/tests/dot_move/dot_move/sources/dotmove.move @@ -0,0 +1,86 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +module dotmove::dotmove { + use std::string::String; + use sui::{ + vec_map::{Self, VecMap}, + dynamic_field as df + }; + use dotmove::name; + + public struct AppInfo has copy, store, drop { + package_info_id: Option, + package_address: Option
, + upgrade_cap_id: Option + } + + public struct AppRecord has store { + // The Capability object used for managing the `AppRecord`. + app_cap_id: ID, + // The core `AppInfo` object. + // This is optional until a `mainnet` (base network) package is mapped to a record, making + // the record immutable. + app_info: Option, + // This is what being resolved across networks. + networks: VecMap, + // Any read-only metadata for the record. + metadata: VecMap, + // Any extra data that needs to be stored. + // Unblocks TTO, and DFs extendability. + storage: UID, + } + + /// The shared object holding the registry of packages. + /// There are no "admin" actions for this registry. + /// + /// For simplicity, on testing, we attach all names directly to the AppRegistry. + public struct AppRegistry has key { + id: UID, + } + + fun init(ctx: &mut TxContext) { + transfer::share_object(AppRegistry { + id: object::new(ctx), + }); + } + + public fun add_record( + registry: &mut AppRegistry, + name: String, + package_address: ID, + ctx: &mut TxContext + ) { + df::add(&mut registry.id, name::new(name), AppRecord { + app_cap_id: @0x0.to_id(), + app_info: option::some(AppInfo { + package_info_id: option::some(package_address), + package_address: option::some(package_address.to_address()), + upgrade_cap_id: option::none() + }), + networks: vec_map::empty(), + metadata: vec_map::empty(), + storage: object::new(ctx), + }); + } + + /// Sets a network's value for a given app name. + public fun set_network( + registry: &mut AppRegistry, + name: String, + package_address: address, + chain_id: String, + ) { + let on_chain_name = name::new(name); + let record: &mut AppRecord = df::borrow_mut(&mut registry.id, on_chain_name); + + if (record.networks.contains(&chain_id)) { + record.networks.remove(&chain_id); + }; + record.networks.insert(chain_id, AppInfo { + package_info_id: option::some(package_address.to_id()), + package_address: option::some(package_address), + upgrade_cap_id: option::none() + }); + } +} diff --git a/crates/sui-graphql-rpc/tests/dot_move/dot_move/sources/name.move b/crates/sui-graphql-rpc/tests/dot_move/dot_move/sources/name.move new file mode 100644 index 0000000000000..3dcdf78e273c3 --- /dev/null +++ b/crates/sui-graphql-rpc/tests/dot_move/dot_move/sources/name.move @@ -0,0 +1,47 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +module dotmove::name { + use std::string::String; + + const SEPARATOR: vector = b"@"; + + public struct Name has copy, store, drop { + // the labels of the label e.g. [org, example]. Keeping them reverse for consistency + // with SuiNS + labels: vector, + // The normalized version of the label (e.g. `example@org`) + normalized: String + } + + public fun new(name: String): Name { + let labels = split_by_separator(name); + + Name { + labels, + normalized: name + } + } + + public(package) fun split_by_separator(mut name: String): vector { + let mut labels: vector = vector[]; + let separator = SEPARATOR.to_string(); + + while(!name.is_empty()) { + let next_separator_index = name.index_of(&separator); + let part = name.sub_string(0, next_separator_index); + labels.push_back(part); + + let next_portion = if (next_separator_index == name.length()) { + name.length() + } else { + next_separator_index + 1 + }; + + name = name.sub_string(next_portion, name.length()); + }; + + labels.reverse(); + labels + } +} diff --git a/crates/sui-graphql-rpc/tests/dot_move_e2e.rs b/crates/sui-graphql-rpc/tests/dot_move_e2e.rs new file mode 100644 index 0000000000000..76e034caef3a4 --- /dev/null +++ b/crates/sui-graphql-rpc/tests/dot_move_e2e.rs @@ -0,0 +1,513 @@ +// Copyright (c) Mysten Labs, Inc. +// SPDX-License-Identifier: Apache-2.0 + +#[cfg(feature = "pg_integration")] +mod tests { + use std::{path::PathBuf, time::Duration}; + + use sui_graphql_rpc::{ + config::{ConnectionConfig, ServiceConfig}, + test_infra::cluster::{ + start_graphql_server_with_fn_rpc, start_network_cluster, + wait_for_graphql_checkpoint_catchup, wait_for_graphql_server, NetworkCluster, + }, + }; + use sui_graphql_rpc_client::simple_client::SimpleClient; + use sui_json_rpc_types::ObjectChange; + use sui_move_build::BuildConfig; + use sui_types::{ + base_types::{ObjectID, SequenceNumber}, + digests::ObjectDigest, + move_package::UpgradePolicy, + object::Owner, + programmable_transaction_builder::ProgrammableTransactionBuilder, + transaction::{CallArg, ObjectArg}, + Identifier, SUI_FRAMEWORK_PACKAGE_ID, + }; + const DOT_MOVE_PKG: &str = "tests/dot_move/dot_move/"; + const DEMO_PKG: &str = "tests/dot_move/demo/"; + const DEMO_PKG_V2: &str = "tests/dot_move/demo_v2/"; + const DEMO_PKG_V3: &str = "tests/dot_move/demo_v3/"; + + const DB_NAME: &str = "sui_graphql_rpc_e2e_tests"; + const DEMO_TYPE: &str = "::demo::V1Type"; + const DEMO_TYPE_V2: &str = "::demo::V2Type"; + const DEMO_TYPE_V3: &str = "::demo::V3Type"; + + #[derive(Clone, Debug)] + struct UpgradeCap(ObjectID, SequenceNumber, ObjectDigest); + + #[tokio::test] + async fn test_dot_move_e2e() { + let network_cluster = + start_network_cluster(gql_default_config(DB_NAME, 8000, 9184), None).await; + + let external_network_chain_id = network_cluster + .validator_fullnode_handle + .fullnode_handle + .sui_client + .read_api() + .get_chain_identifier() + .await + .unwrap(); + + eprintln!("External chain id: {:?}", external_network_chain_id); + + // publish the dot move package in the internal resolution cluster. + let (pkg_id, registry_id) = publish_dot_move_package(&network_cluster).await; + + let (v1, v2, v3) = publish_demo_pkg(&network_cluster).await; + + let name = "app@org".to_string(); + + // Register the package: First, for the "base" chain state. + register_pkg( + &network_cluster, + pkg_id, + registry_id, + v1, + name.clone(), + None, + ) + .await; + + // Register the package for the external resolver. + register_pkg( + &network_cluster, + pkg_id, + registry_id, + v1, + name.clone(), + Some(external_network_chain_id.clone()), + ) + .await; + + // Initialize the internal and external clients of GraphQL. + + // The first cluster uses internal resolution (mimics our base network, does not rely on external chain). + let internal_client = init_dot_move_gql( + 8000, + 9184, + ServiceConfig::dot_move_test_defaults( + false, + None, + Some(pkg_id.into()), + Some(registry_id.0), + None, + ), + ) + .await; + + let external_client = init_dot_move_gql( + 8001, + 9185, + ServiceConfig::dot_move_test_defaults( + true, // external resolution + Some(internal_client.url()), + Some(pkg_id.into()), + Some(registry_id.0), + None, + ), + ) + .await; + + // Await for the internal cluster to catch up with the latest checkpoint. + // That way we're certain that the data is available for querying (committed & indexed). + let latest_checkpoint = network_cluster + .validator_fullnode_handle + .fullnode_handle + .sui_node + .inner() + .state() + .get_latest_checkpoint_sequence_number() + .expect("To have a checkpoint"); + + eprintln!("Latest checkpoint: {:?}", latest_checkpoint); + + wait_for_graphql_checkpoint_catchup( + &internal_client, + latest_checkpoint, + Duration::from_millis(500), + ) + .await; + + // We craft a big query, which we'll use to test both the internal and the external resolution. + // Same query is used across both nodes, since we're testing on top of the same data, just with a different + // lookup approach. + let query = format!( + r#"{{ valid_latest: {}, v1: {}, v2: {}, v3: {}, v4: {}, v1_type: {}, v2_type: {}, v3_type: {} }}"#, + name_query(&name), + name_query(&format!("{}{}", &name, "/v1")), + name_query(&format!("{}{}", &name, "/v2")), + name_query(&format!("{}{}", &name, "/v3")), + name_query(&format!("{}{}", &name, "/v4")), + type_query(&format!("{}{}", &name, DEMO_TYPE)), + type_query(&format!("{}{}", &name, DEMO_TYPE_V2)), + type_query(&format!("{}{}", &name, DEMO_TYPE_V3)), + ); + + let internal_resolution = internal_client + .execute(query.clone(), vec![]) + .await + .unwrap(); + + let external_resolution = external_client + .execute(query.clone(), vec![]) + .await + .unwrap(); + + test_results(internal_resolution, &v1, &v2, &v3, "internal resolution"); + test_results(external_resolution, &v1, &v2, &v3, "external resolution"); + + network_cluster.cleanup_resources().await; + eprintln!("Tests have finished successfully now!"); + } + + fn test_results( + query_result: serde_json::Value, + v1: &ObjectID, + v2: &ObjectID, + v3: &ObjectID, + // an indicator to help identify the test case that failed using this. + indicator: &str, + ) { + eprintln!("Testing results for: {}", indicator); + assert_eq!( + query_result["data"]["valid_latest"]["address"] + .as_str() + .unwrap(), + v3.to_string(), + "The latest version should have been v3", + ); + + assert_eq!( + query_result["data"]["v1"]["address"].as_str().unwrap(), + v1.to_string(), + "V1 response did not correspond to the expected value", + ); + + assert_eq!( + query_result["data"]["v2"]["address"].as_str().unwrap(), + v2.to_string(), + "V2 response did not correspond to the expected value", + ); + + assert_eq!( + query_result["data"]["v3"]["address"].as_str().unwrap(), + v3.to_string(), + "V3 response did not correspond to the expected value", + ); + + assert!( + query_result["data"]["v4"].is_null(), + "V4 should not have been found" + ); + + assert_eq!( + query_result["data"]["v1_type"]["layout"]["struct"]["type"] + .as_str() + .unwrap(), + format!("{}{}", v1, DEMO_TYPE) + ); + + assert_eq!( + query_result["data"]["v2_type"]["layout"]["struct"]["type"] + .as_str() + .unwrap(), + format!("{}{}", v2, DEMO_TYPE_V2) + ); + + assert_eq!( + query_result["data"]["v3_type"]["layout"]["struct"]["type"] + .as_str() + .unwrap(), + format!("{}{}", v3, DEMO_TYPE_V3) + ); + } + + async fn init_dot_move_gql( + gql_port: u16, + prom_port: u16, + config: ServiceConfig, + ) -> SimpleClient { + let cfg = gql_default_config(DB_NAME, gql_port, prom_port); + + let _gql_handle = start_graphql_server_with_fn_rpc(cfg.clone(), None, None, config).await; + + let server_url = format!("http://{}:{}/", cfg.host(), cfg.port()); + + // Starts graphql client + let client = SimpleClient::new(server_url); + wait_for_graphql_server(&client).await; + + client + } + + async fn register_pkg( + cluster: &NetworkCluster, + dot_move_package_id: ObjectID, + registry_id: (ObjectID, SequenceNumber), + package_id: ObjectID, + name: String, + chain_id: Option, + ) { + let is_network_call = chain_id.is_some(); + let function = if is_network_call { + "set_network" + } else { + "add_record" + }; + + let mut args = vec![ + CallArg::Object(ObjectArg::SharedObject { + id: registry_id.0, + initial_shared_version: registry_id.1, + mutable: true, + }), + CallArg::from(&name.as_bytes().to_vec()), + CallArg::Pure(bcs::to_bytes(&package_id).unwrap()), + ]; + + if let Some(ref chain_id) = chain_id { + args.push(CallArg::from(&chain_id.as_bytes().to_vec())); + }; + + let tx = cluster + .validator_fullnode_handle + .test_transaction_builder() + .await + .move_call(dot_move_package_id, "dotmove", function, args) + .build(); + + cluster + .validator_fullnode_handle + .sign_and_execute_transaction(&tx) + .await; + + eprintln!("Added record successfully: {:?}", (name, chain_id)); + } + + // Publishes the Demo PKG, upgrades it twice and returns v1, v2 and v3 package ids. + async fn publish_demo_pkg(cluster: &NetworkCluster) -> (ObjectID, ObjectID, ObjectID) { + let tx = cluster + .validator_fullnode_handle + .test_transaction_builder() + .await + .publish(PathBuf::from(DEMO_PKG)) + .build(); + + let executed = cluster + .validator_fullnode_handle + .sign_and_execute_transaction(&tx) + .await; + let object_changes = executed.object_changes.unwrap(); + + let v1 = object_changes + .iter() + .find_map(|object| { + if let ObjectChange::Published { package_id, .. } = object { + Some(*package_id) + } else { + None + } + }) + .unwrap(); + + let upgrade_cap = object_changes + .iter() + .find_map(|object| { + if let ObjectChange::Created { + object_id, + object_type, + digest, + version, + .. + } = object + { + if object_type.module.as_str() == "package" + && object_type.name.as_str() == "UpgradeCap" + { + Some(UpgradeCap(*object_id, *version, *digest)) + } else { + None + } + } else { + None + } + }) + .unwrap(); + + let (v2, upgrade_cap) = upgrade_pkg(cluster, DEMO_PKG_V2, upgrade_cap, v1).await; + let (v3, _) = upgrade_pkg(cluster, DEMO_PKG_V3, upgrade_cap, v2).await; + + (v1, v2, v3) + } + + async fn upgrade_pkg( + cluster: &NetworkCluster, + package_path: &str, + upgrade_cap: UpgradeCap, + current_package_object_id: ObjectID, + ) -> (ObjectID, UpgradeCap) { + // build the package upgrade to V2. + let mut builder = ProgrammableTransactionBuilder::new(); + + let compiled_package = BuildConfig::new_for_testing() + .build(&PathBuf::from(package_path)) + .unwrap(); + let digest = compiled_package.get_package_digest(false); + let modules = compiled_package.get_package_bytes(false); + let dependencies = compiled_package.get_dependency_storage_package_ids(); + + let cap = builder + .obj(ObjectArg::ImmOrOwnedObject(( + upgrade_cap.0, + upgrade_cap.1, + upgrade_cap.2, + ))) + .unwrap(); + + let policy = builder.pure(UpgradePolicy::Compatible as u8).unwrap(); + + let digest = builder.pure(digest.to_vec()).unwrap(); + + let ticket = builder.programmable_move_call( + SUI_FRAMEWORK_PACKAGE_ID, + Identifier::new("package").unwrap(), + Identifier::new("authorize_upgrade").unwrap(), + vec![], + vec![cap, policy, digest], + ); + + let receipt = builder.upgrade(current_package_object_id, ticket, dependencies, modules); + + builder.programmable_move_call( + SUI_FRAMEWORK_PACKAGE_ID, + Identifier::new("package").unwrap(), + Identifier::new("commit_upgrade").unwrap(), + vec![], + vec![cap, receipt], + ); + + let tx = cluster + .validator_fullnode_handle + .test_transaction_builder() + .await + .programmable(builder.finish()) + .build(); + + let upgraded = cluster + .validator_fullnode_handle + .sign_and_execute_transaction(&tx) + .await; + + let object_changes = upgraded.object_changes.unwrap(); + + let pkg_id = object_changes + .iter() + .find_map(|object| { + if let ObjectChange::Published { package_id, .. } = object { + Some(*package_id) + } else { + None + } + }) + .unwrap(); + + let upgrade_cap = object_changes + .iter() + .find_map(|object| { + if let ObjectChange::Mutated { + object_id, + object_type, + digest, + version, + .. + } = object + { + if object_type.module.as_str() == "package" + && object_type.name.as_str() == "UpgradeCap" + { + Some(UpgradeCap(*object_id, *version, *digest)) + } else { + None + } + } else { + None + } + }) + .unwrap(); + + (pkg_id, upgrade_cap) + } + + async fn publish_dot_move_package( + cluster: &NetworkCluster, + ) -> (ObjectID, (ObjectID, SequenceNumber)) { + let package_path = PathBuf::from(DOT_MOVE_PKG); + let tx = cluster + .validator_fullnode_handle + .test_transaction_builder() + .await + .publish(package_path) + .build(); + + let sig = cluster + .validator_fullnode_handle + .wallet + .sign_transaction(&tx); + + let executed = cluster + .validator_fullnode_handle + .execute_transaction(sig) + .await; + + let (mut pkg_id, mut obj_id) = (None, None); + + for object in executed.object_changes.unwrap() { + match object { + ObjectChange::Published { package_id, .. } => { + pkg_id = Some(package_id); + } + ObjectChange::Created { + object_id, + object_type, + owner, + .. + } => { + if object_type.module.as_str() == "dotmove" + && object_type.name.as_str() == "AppRegistry" + { + let initial_shared_version = match owner { + Owner::Shared { + initial_shared_version, + } => initial_shared_version, + _ => panic!("AppRegistry should be shared"), + }; + + if !owner.is_shared() { + panic!("AppRegistry should be shared"); + }; + + obj_id = Some((object_id, initial_shared_version)); + } + } + _ => {} + } + } + + (pkg_id.unwrap(), obj_id.unwrap()) + } + + fn name_query(name: &str) -> String { + format!(r#"packageByName(name: "{}") {{ address, version }}"#, name) + } + + fn type_query(named_type: &str) -> String { + format!(r#"typeByName(name: "{}") {{ layout }}"#, named_type) + } + + fn gql_default_config(db_name: &str, port: u16, prom_port: u16) -> ConnectionConfig { + ConnectionConfig::ci_integration_test_cfg_with_db_name(db_name.to_string(), port, prom_port) + } +} diff --git a/crates/sui-graphql-rpc/tests/e2e_tests.rs b/crates/sui-graphql-rpc/tests/e2e_tests.rs index 7b264e3bf8bac..23d37ed93d147 100644 --- a/crates/sui-graphql-rpc/tests/e2e_tests.rs +++ b/crates/sui-graphql-rpc/tests/e2e_tests.rs @@ -14,6 +14,8 @@ mod tests { use sui_graphql_rpc::client::simple_client::GraphqlQueryVariable; use sui_graphql_rpc::client::ClientError; use sui_graphql_rpc::config::ConnectionConfig; + use sui_graphql_rpc::config::ServiceConfig; + use sui_graphql_rpc::test_infra::cluster::start_cluster; use sui_graphql_rpc::test_infra::cluster::ExecutorCluster; use sui_graphql_rpc::test_infra::cluster::DEFAULT_INTERNAL_DATA_SOURCE_PORT; use sui_types::digests::ChainIdentifier; @@ -61,9 +63,12 @@ mod tests { .with_env() .init(); - let cluster = - sui_graphql_rpc::test_infra::cluster::start_cluster(ConnectionConfig::default(), None) - .await; + let cluster = start_cluster( + ConnectionConfig::default(), + None, + ServiceConfig::test_defaults(), + ) + .await; cluster .wait_for_checkpoint_catchup(1, Duration::from_secs(10)) @@ -80,6 +85,7 @@ mod tests { .await .unwrap(); let chain_id_actual = cluster + .network .validator_fullnode_handle .fullnode_handle .sui_client @@ -320,21 +326,27 @@ mod tests { .with_env() .init(); - let cluster = - sui_graphql_rpc::test_infra::cluster::start_cluster(ConnectionConfig::default(), None) - .await; + let connection_config = ConnectionConfig::ci_integration_test_cfg(); - let addresses = cluster.validator_fullnode_handle.wallet.get_addresses(); + let cluster = start_cluster(connection_config, None, ServiceConfig::test_defaults()).await; + + let addresses = cluster + .network + .validator_fullnode_handle + .wallet + .get_addresses(); let sender = addresses[0]; let recipient = addresses[1]; let tx = cluster + .network .validator_fullnode_handle .test_transaction_builder() .await .transfer_sui(Some(1_000), recipient) .build(); let signed_tx = cluster + .network .validator_fullnode_handle .wallet .sign_transaction(&tx); @@ -431,11 +443,14 @@ mod tests { .with_env() .init(); - let cluster = - sui_graphql_rpc::test_infra::cluster::start_cluster(ConnectionConfig::default(), None) - .await; + let cluster = start_cluster( + ConnectionConfig::default(), + None, + ServiceConfig::test_defaults(), + ) + .await; - let test_cluster = &cluster.validator_fullnode_handle; + let test_cluster = &cluster.network.validator_fullnode_handle; test_cluster.wait_for_epoch_all_nodes(1).await; test_cluster.wait_for_authenticator_state_update().await; @@ -545,15 +560,23 @@ mod tests { .with_env() .init(); - let cluster = - sui_graphql_rpc::test_infra::cluster::start_cluster(ConnectionConfig::default(), None) - .await; + let cluster = start_cluster( + ConnectionConfig::default(), + None, + ServiceConfig::test_defaults(), + ) + .await; - let addresses = cluster.validator_fullnode_handle.wallet.get_addresses(); + let addresses = cluster + .network + .validator_fullnode_handle + .wallet + .get_addresses(); let sender = addresses[0]; let recipient = addresses[1]; let tx = cluster + .network .validator_fullnode_handle .test_transaction_builder() .await @@ -639,14 +662,22 @@ mod tests { .with_env() .init(); - let cluster = - sui_graphql_rpc::test_infra::cluster::start_cluster(ConnectionConfig::default(), None) - .await; + let cluster = start_cluster( + ConnectionConfig::default(), + None, + ServiceConfig::test_defaults(), + ) + .await; - let addresses = cluster.validator_fullnode_handle.wallet.get_addresses(); + let addresses = cluster + .network + .validator_fullnode_handle + .wallet + .get_addresses(); let recipient = addresses[1]; let tx = cluster + .network .validator_fullnode_handle .test_transaction_builder() .await @@ -710,14 +741,22 @@ mod tests { .with_env() .init(); - let cluster = - sui_graphql_rpc::test_infra::cluster::start_cluster(ConnectionConfig::default(), None) - .await; + let cluster = start_cluster( + ConnectionConfig::default(), + None, + ServiceConfig::test_defaults(), + ) + .await; - let addresses = cluster.validator_fullnode_handle.wallet.get_addresses(); + let addresses = cluster + .network + .validator_fullnode_handle + .wallet + .get_addresses(); let sender = addresses[0]; let coin = *cluster + .network .validator_fullnode_handle .wallet .get_gas_objects_owned_by_address(sender, None) @@ -726,6 +765,7 @@ mod tests { .get(1) .unwrap(); let tx = cluster + .network .validator_fullnode_handle .test_transaction_builder() .await @@ -799,11 +839,15 @@ mod tests { .with_env() .init(); - let cluster = - sui_graphql_rpc::test_infra::cluster::start_cluster(ConnectionConfig::default(), None) - .await; + let cluster = start_cluster( + ConnectionConfig::default(), + None, + ServiceConfig::test_defaults(), + ) + .await; cluster + .network .validator_fullnode_handle .trigger_reconfiguration() .await; @@ -846,15 +890,18 @@ mod tests { let _guard = telemetry_subscribers::TelemetryConfig::new() .with_env() .init(); - let cluster = - sui_graphql_rpc::test_infra::cluster::start_cluster(ConnectionConfig::default(), None) - .await; + let cluster = start_cluster( + ConnectionConfig::default(), + None, + ServiceConfig::test_defaults(), + ) + .await; cluster .wait_for_checkpoint_catchup(1, Duration::from_secs(10)) .await; // timeout test includes mutation timeout, which requies a [SuiClient] to be able to run // the test, and a transaction. [WalletContext] gives access to everything that's needed. - let wallet = &cluster.validator_fullnode_handle.wallet; + let wallet = &cluster.network.validator_fullnode_handle.wallet; test_timeout_impl(wallet).await; cluster.cleanup_resources().await } @@ -897,8 +944,12 @@ mod tests { .with_env() .init(); let connection_config = ConnectionConfig::ci_integration_test_cfg(); - let cluster = - sui_graphql_rpc::test_infra::cluster::start_cluster(connection_config, None).await; + let cluster = sui_graphql_rpc::test_infra::cluster::start_cluster( + connection_config, + None, + ServiceConfig::test_defaults(), + ) + .await; // We wait until checkpoint 1 is indexed, to give enough time to the // watermark task to pick up a valid checkpoint timestamp. diff --git a/crates/sui-graphql-rpc/tests/snapshots/snapshot_tests__schema_sdl_export.snap b/crates/sui-graphql-rpc/tests/snapshots/snapshot_tests__schema_sdl_export.snap index ed3ec693362f4..f372497ad3a57 100644 --- a/crates/sui-graphql-rpc/tests/snapshots/snapshot_tests__schema_sdl_export.snap +++ b/crates/sui-graphql-rpc/tests/snapshots/snapshot_tests__schema_sdl_export.snap @@ -1353,6 +1353,10 @@ enum Feature { validators either directly, or through system transactions. """ SYSTEM_STATE + """ + Named packages service (utilizing dotmove package registry). + """ + MOVE_REGISTRY } @@ -3351,6 +3355,14 @@ type Query { """ resolveSuinsAddress(domain: String!): Address """ + Fetch a package by its name (using dot move service) + """ + packageByName(name: String!): MovePackage + """ + Fetch a type that includes dot move service names in it. + """ + typeByName(name: String!): MoveType! + """ The coin metadata associated with the given coin type. """ coinMetadata(coinType: String!): CoinMetadata @@ -4711,4 +4723,3 @@ schema { query: Query mutation: Mutation } - diff --git a/crates/sui-types/src/collection_types.rs b/crates/sui-types/src/collection_types.rs index b81acc951adf4..da87398fd4955 100644 --- a/crates/sui-types/src/collection_types.rs +++ b/crates/sui-types/src/collection_types.rs @@ -12,6 +12,14 @@ pub struct VecMap { pub contents: Vec>, } +impl VecMap { + pub fn get(&self, key: &K) -> Option<&V> { + self.contents + .iter() + .find_map(|entry| (&entry.key == key).then_some(&entry.value)) + } +} + /// Rust version of the Move sui::vec_map::Entry type #[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq)] pub struct Entry { @@ -101,3 +109,31 @@ impl Default for Bag { } } } + +#[cfg(test)] +mod tests { + use super::VecMap; + + #[test] + fn test_vec_map() { + let vec_map = VecMap { + contents: vec![ + ("key1".to_string(), "value1".to_string()), + ("key2".to_string(), "value2".to_string()), + ] + .into_iter() + .map(|(key, value)| super::Entry { key, value }) + .collect(), + }; + + assert_eq!( + vec_map.get(&"key1".to_string()), + Some(&"value1".to_string()) + ); + assert_eq!( + vec_map.get(&"key2".to_string()), + Some(&"value2".to_string()) + ); + assert_eq!(vec_map.get(&"key3".to_string()), None); + } +} diff --git a/crates/sui/src/sui_commands.rs b/crates/sui/src/sui_commands.rs index 1c44c0aeb8d4a..0e853d7051fd0 100644 --- a/crates/sui/src/sui_commands.rs +++ b/crates/sui/src/sui_commands.rs @@ -34,7 +34,8 @@ use sui_config::{ use sui_faucet::{create_wallet_context, start_faucet, AppState, FaucetConfig, SimpleFaucet}; #[cfg(feature = "indexer")] use sui_graphql_rpc::{ - config::ConnectionConfig, test_infra::cluster::start_graphql_server_with_fn_rpc, + config::{ConnectionConfig, ServiceConfig}, + test_infra::cluster::start_graphql_server_with_fn_rpc, }; #[cfg(feature = "indexer")] use sui_indexer::test_utils::{start_test_indexer, ReaderWriterConfig}; @@ -737,6 +738,7 @@ async fn start( graphql_connection_config, Some(fullnode_url.clone()), None, // it will be initialized by default + ServiceConfig::test_defaults(), ) .await; info!("GraphQL started");