diff --git a/CHANGELOG.md b/CHANGELOG.md index fdd6424972a..9fc46d79863 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] ### Added +- [2445](https://github.com/FuelLabs/fuel-core/pull/2445): Added GQL endpoint for querying asset details. - [2154](https://github.com/FuelLabs/fuel-core/pull/2154): Added `Unknown` variant to `ConsensusParameters` graphql queries - [2154](https://github.com/FuelLabs/fuel-core/pull/2154): Added `Unknown` variant to `Block` graphql queries - [2154](https://github.com/FuelLabs/fuel-core/pull/2154): Added `TransactionType` type in `fuel-client` diff --git a/crates/client/assets/schema.sdl b/crates/client/assets/schema.sdl index 54db4ec0995..bdcaa5e2a64 100644 --- a/crates/client/assets/schema.sdl +++ b/crates/client/assets/schema.sdl @@ -2,6 +2,12 @@ scalar Address scalar AssetId +type AssetInfoDetails { + contractId: HexString! + subId: HexString! + totalSupply: U64! +} + type Balance { owner: Address! amount: U64! @@ -850,6 +856,12 @@ type ProgramState { } type Query { + assetDetails( + """ + ID of the Asset + """ + id: AssetId! + ): AssetInfoDetails """ Read register value by index. """ diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index 6cd9023276f..70651e46a09 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -78,6 +78,10 @@ use pagination::{ PaginationRequest, }; use schema::{ + assets::{ + AssetInfoArg, + AssetInfoDetails, + }, balance::BalanceArgs, blob::BlobByIdArgs, block::BlockByIdArgs, @@ -1191,6 +1195,17 @@ impl FuelClient { .transpose()?; Ok(status) } + + pub async fn asset_info( + &self, + asset_id: &AssetId, + ) -> io::Result> { + let query = schema::assets::AssetInfoQuery::build(AssetInfoArg { + id: (*asset_id).into(), + }); + let asset_info = self.query(query).await?.asset_details.map(Into::into); + Ok(asset_info) + } } #[cfg(any(test, feature = "test-helpers"))] diff --git a/crates/client/src/client/schema.rs b/crates/client/src/client/schema.rs index 7930d66c1ab..aec28be17c8 100644 --- a/crates/client/src/client/schema.rs +++ b/crates/client/src/client/schema.rs @@ -26,6 +26,7 @@ use crate::client::pagination::{ }; pub use primitives::*; +pub mod assets; pub mod balance; pub mod blob; pub mod block; diff --git a/crates/client/src/client/schema/assets.rs b/crates/client/src/client/schema/assets.rs new file mode 100644 index 00000000000..4d6d808bbae --- /dev/null +++ b/crates/client/src/client/schema/assets.rs @@ -0,0 +1,30 @@ +use crate::client::schema::{ + schema, + AssetId, + HexString, + U64, +}; + +#[derive(cynic::QueryVariables, Debug)] +pub struct AssetInfoArg { + pub id: AssetId, +} + +#[derive(cynic::QueryFragment, Clone, Debug)] +#[cynic( + schema_path = "./assets/schema.sdl", + graphql_type = "Query", + variables = "AssetInfoArg" +)] +pub struct AssetInfoQuery { + #[arguments(id: $id)] + pub asset_details: Option, +} + +#[derive(cynic::QueryFragment, Clone, Debug)] +#[cynic(schema_path = "./assets/schema.sdl")] +pub struct AssetInfoDetails { + pub sub_id: HexString, + pub contract_id: HexString, + pub total_supply: U64, +} diff --git a/crates/fuel-core/src/graphql_api/ports.rs b/crates/fuel-core/src/graphql_api/ports.rs index 077a48d1637..3cf6a795029 100644 --- a/crates/fuel-core/src/graphql_api/ports.rs +++ b/crates/fuel-core/src/graphql_api/ports.rs @@ -64,6 +64,8 @@ use fuel_core_types::{ }; use std::sync::Arc; +use super::storage::assets::AssetDetails; + pub trait OffChainDatabase: Send + Sync { fn block_height(&self, block_id: &BlockId) -> StorageResult; @@ -112,6 +114,10 @@ pub trait OffChainDatabase: Send + Sync { ) -> StorageResult>; fn message_is_spent(&self, nonce: &Nonce) -> StorageResult; + + fn asset_info(&self, asset_id: &AssetId) -> StorageResult>; + + fn asset_exists(&self, asset_id: &AssetId) -> StorageResult; } /// The on chain database port expected by GraphQL API service. @@ -273,6 +279,7 @@ pub mod worker { }, }, graphql_api::storage::{ + assets::AssetsInfo, da_compression::*, old::{ OldFuelBlockConsensus, @@ -336,6 +343,7 @@ pub mod worker { + StorageMutate + StorageMutate + StorageMutate + + StorageMutate { fn record_tx_id_owner( &mut self, diff --git a/crates/fuel-core/src/graphql_api/storage.rs b/crates/fuel-core/src/graphql_api/storage.rs index 8f8cfcd1f19..fbcdc963e94 100644 --- a/crates/fuel-core/src/graphql_api/storage.rs +++ b/crates/fuel-core/src/graphql_api/storage.rs @@ -36,6 +36,7 @@ use fuel_core_types::{ }; use statistic::StatisticTable; +pub mod assets; pub mod blocks; pub mod coins; pub mod contracts; @@ -113,6 +114,8 @@ pub enum Column { DaCompressionTemporalRegistryScriptCode = 21, /// See [`DaCompressionTemporalRegistryPredicateCode`](da_compression::DaCompressionTemporalRegistryPredicateCode) DaCompressionTemporalRegistryPredicateCode = 22, + /// See [`AssetsInfo`](assets::AssetsInfo) + AssetsInfo = 23, } impl Column { diff --git a/crates/fuel-core/src/graphql_api/storage/assets.rs b/crates/fuel-core/src/graphql_api/storage/assets.rs new file mode 100644 index 00000000000..5132ed21f8f --- /dev/null +++ b/crates/fuel-core/src/graphql_api/storage/assets.rs @@ -0,0 +1,48 @@ +use fuel_core_storage::{ + blueprint::plain::Plain, + codec::{ + postcard::Postcard, + raw::Raw, + }, + structured_storage::TableWithBlueprint, + Mappable, +}; +use fuel_core_types::{ + fuel_merkle::common::Bytes32, + fuel_tx::{ + AssetId, + ContractId, + }, +}; + +/// Contract info +pub struct AssetsInfo; + +pub type AssetDetails = (ContractId, Bytes32, u64); // (contract_id, sub_id, total_amount) + +impl Mappable for AssetsInfo { + type Key = AssetId; + type OwnedKey = Self::Key; + type Value = Self::OwnedValue; + type OwnedValue = AssetDetails; +} + +impl TableWithBlueprint for AssetsInfo { + type Blueprint = Plain; + type Column = super::Column; + + fn column() -> Self::Column { + Self::Column::AssetsInfo + } +} + +#[cfg(test)] +mod test { + use super::*; + + fuel_core_storage::basic_storage_tests!( + AssetsInfo, + ::Key::default(), + ::Value::default() + ); +} diff --git a/crates/fuel-core/src/graphql_api/worker_service.rs b/crates/fuel-core/src/graphql_api/worker_service.rs index 8d5b0bc923f..f295b487af0 100644 --- a/crates/fuel-core/src/graphql_api/worker_service.rs +++ b/crates/fuel-core/src/graphql_api/worker_service.rs @@ -1,9 +1,12 @@ use super::{ da_compression::da_compress_block, - storage::old::{ - OldFuelBlockConsensus, - OldFuelBlocks, - OldTransactions, + storage::{ + assets::AssetsInfo, + old::{ + OldFuelBlockConsensus, + OldFuelBlocks, + OldTransactions, + }, }, }; use crate::{ @@ -63,9 +66,11 @@ use fuel_core_types::{ CoinPredicate, CoinSigned, }, + AssetId, Contract, Input, Output, + Receipt, Transaction, TxId, UniqueIdentifier, @@ -356,6 +361,54 @@ where ) .into()); } + + for receipt in result.receipts() { + match receipt { + Receipt::Mint { + sub_id, + contract_id, + val, + .. + } => { + let asset_id = AssetId::from(**contract_id); + let current_count = db + .storage::() + .get(&asset_id)? + .map(|info| { + info.2 + .checked_add(*val) + .ok_or(anyhow::anyhow!("Asset count overflow")) + }) + .transpose()? + .unwrap_or(*val); + + db.storage::() + .insert(&asset_id, &(*contract_id, **sub_id, current_count))?; + } + Receipt::Burn { + sub_id, + contract_id, + val, + .. + } => { + let asset_id = AssetId::from(**contract_id); + let current_count = db + .storage::() + .get(&asset_id)? + .map(|info| { + info.2 + .checked_sub(*val) + .ok_or(anyhow::anyhow!("Asset count overflow")) + }) + .transpose()? + .unwrap_or(*val); + + db.storage::() + .insert(&asset_id, &(*contract_id, **sub_id, current_count))?; + } + _ => {} + } + } } Ok(()) } diff --git a/crates/fuel-core/src/query.rs b/crates/fuel-core/src/query.rs index 0dc744bcec0..3b0005967c7 100644 --- a/crates/fuel-core/src/query.rs +++ b/crates/fuel-core/src/query.rs @@ -1,3 +1,4 @@ +mod assets; mod balance; mod blob; mod block; diff --git a/crates/fuel-core/src/query/assets.rs b/crates/fuel-core/src/query/assets.rs new file mode 100644 index 00000000000..5713397aa15 --- /dev/null +++ b/crates/fuel-core/src/query/assets.rs @@ -0,0 +1,19 @@ +use crate::{ + fuel_core_graphql_api::database::ReadView, + graphql_api::storage::assets::AssetDetails, +}; +use fuel_core_storage::{ + not_found, + Result as StorageResult, +}; +use fuel_core_types::fuel_tx::AssetId; + +impl ReadView { + pub fn get_asset_details(&self, id: AssetId) -> StorageResult { + let asset = self + .off_chain + .asset_info(&id)? + .ok_or(not_found!(AssetDetails))?; + Ok(asset) + } +} diff --git a/crates/fuel-core/src/schema.rs b/crates/fuel-core/src/schema.rs index bcbc5b5c970..124a33ef5ad 100644 --- a/crates/fuel-core/src/schema.rs +++ b/crates/fuel-core/src/schema.rs @@ -30,6 +30,7 @@ use futures::{ use std::borrow::Cow; use tokio_stream::StreamExt; +pub mod assets; pub mod balance; pub mod blob; pub mod block; @@ -51,6 +52,7 @@ pub mod relayed_tx; #[derive(MergedObject, Default)] pub struct Query( + assets::AssetInfoQuery, dap::DapQuery, balance::BalanceQuery, blob::BlobQuery, @@ -142,7 +144,7 @@ where } else if let Some(last) = last { (last, IterDirection::Reverse) } else { - return Err(anyhow!("Either `first` or `last` should be provided")) + return Err(anyhow!("Either `first` or `last` should be provided")); }; let start; @@ -170,7 +172,7 @@ where // Skip until start + 1 if key == start { has_previous_page = true; - return true + return true; } } } @@ -184,7 +186,7 @@ where // take until we've reached the end if key == end { has_next_page = true; - return false + return false; } } count = count.saturating_sub(1); diff --git a/crates/fuel-core/src/schema/assets.rs b/crates/fuel-core/src/schema/assets.rs new file mode 100644 index 00000000000..5ec8250cc86 --- /dev/null +++ b/crates/fuel-core/src/schema/assets.rs @@ -0,0 +1,68 @@ +use async_graphql::{ + Context, + Object, +}; + +use crate::{ + fuel_core_graphql_api::query_costs, + graphql_api::storage::assets::AssetDetails, + schema::{ + scalars::{ + AssetId, + HexString, + U64, + }, + ReadViewProvider, + }, +}; + +#[derive(Default)] +pub struct AssetInfoQuery; + +#[Object] +impl AssetInfoQuery { + #[graphql(complexity = "query_costs().storage_read")] + async fn asset_details( + &self, + ctx: &Context<'_>, + #[graphql(desc = "ID of the Asset")] id: AssetId, + ) -> async_graphql::Result> { + let query = ctx.read_view()?; + query + .get_asset_details(id.into()) + .map(|details| Some(details.into())) + .map_err(async_graphql::Error::from) + } +} + +#[derive(Clone, Debug)] +pub struct AssetInfoDetails { + pub contract_id: HexString, + pub sub_id: HexString, + pub total_supply: U64, +} + +impl From for AssetInfoDetails { + fn from(details: AssetDetails) -> Self { + AssetInfoDetails { + contract_id: details.0.as_ref().to_vec().into(), + sub_id: details.1.as_ref().to_vec().into(), + total_supply: details.2.into(), + } + } +} + +#[Object] +impl AssetInfoDetails { + async fn contract_id(&self) -> &HexString { + &self.contract_id + } + + async fn sub_id(&self) -> &HexString { + &self.sub_id + } + + async fn total_supply(&self) -> &U64 { + &self.total_supply + } +} diff --git a/crates/fuel-core/src/service/adapters/graphql_api/off_chain.rs b/crates/fuel-core/src/service/adapters/graphql_api/off_chain.rs index d554c7ddc45..11d0cba5e6d 100644 --- a/crates/fuel-core/src/service/adapters/graphql_api/off_chain.rs +++ b/crates/fuel-core/src/service/adapters/graphql_api/off_chain.rs @@ -16,10 +16,16 @@ use crate::{ transactions::OwnedTransactionIndexCursor, }, }, - graphql_api::storage::old::{ - OldFuelBlockConsensus, - OldFuelBlocks, - OldTransactions, + graphql_api::storage::{ + assets::{ + AssetDetails, + AssetsInfo, + }, + old::{ + OldFuelBlockConsensus, + OldFuelBlocks, + OldTransactions, + }, }, }; use fuel_core_storage::{ @@ -51,6 +57,7 @@ use fuel_core_types::{ entities::relayer::transaction::RelayedTransactionStatus, fuel_tx::{ Address, + AssetId, Bytes32, ContractId, Salt, @@ -187,6 +194,16 @@ impl OffChainDatabase for OffChainIterableKeyValueView { fn message_is_spent(&self, nonce: &Nonce) -> StorageResult { self.message_is_spent(nonce) } + + fn asset_info(&self, asset_id: &AssetId) -> StorageResult> { + self.storage_as_ref::() + .get(asset_id) + .map(|opt| opt.map(|cow| cow.into_owned())) + } + + fn asset_exists(&self, asset_id: &AssetId) -> StorageResult { + self.storage_as_ref::().contains_key(asset_id) + } } impl worker::OffChainDatabase for Database { diff --git a/tests/tests/assets.rs b/tests/tests/assets.rs new file mode 100644 index 00000000000..a13c9ccc123 --- /dev/null +++ b/tests/tests/assets.rs @@ -0,0 +1,117 @@ +use fuel_core::{ + chain_config::{ + CoinConfig, + StateConfig, + }, + database::Database, + service::{ + Config, + FuelService, + }, +}; +use fuel_core_client::client::{ + types::{ + primitives::AssetId, + TransactionStatus, + }, + FuelClient, +}; +use fuel_core_types::fuel_tx::{ + Input, + Output, + TransactionBuilder, + TxId, +}; + +async fn setup_service(configs: Vec) -> FuelService { + let state = StateConfig { + coins: configs, + ..Default::default() + }; + let config = Config::local_node_with_state_config(state); + + FuelService::from_database(Database::default(), config) + .await + .unwrap() +} + +#[tokio::test] +#[ignore] // TODO: Need to be able to mint assets for this test to work +async fn asset_info() { + // setup test data in the node + let output_index = 5; + let tx_id = TxId::new([1u8; 32]); + let coin = CoinConfig { + output_index, + tx_id, + ..Default::default() + }; + + // setup server & client + let srv = setup_service(vec![coin]).await; + let client = FuelClient::from(srv.bound_address); + + // run test + let asset_id = AssetId::new([1u8; 32]); + let mint_amount = 100; + let burn_amount = 50; + + // Mint coins first + let mint_tx = TransactionBuilder::mint( + 0u32.into(), + 0, + Default::default(), + Default::default(), + mint_amount, + asset_id, + Default::default(), + ) + .finalize_as_transaction(); + + let status = client.submit_and_await_commit(&mint_tx).await.unwrap(); + assert!(matches!(status, TransactionStatus::Success { .. })); + + // Query asset info before burn + let initial_supply = client + .asset_info(&asset_id) + .await + .unwrap() + .unwrap() + .total_supply + .0; + + // We should have the minted amount first + assert_eq!(initial_supply, mint_amount); + + // Create and submit transaction that burns coins + let tx = TransactionBuilder::script(vec![], vec![]) + .add_input(Input::coin_signed( + Default::default(), + Default::default(), + burn_amount, + asset_id, + Default::default(), + 0, + )) + .add_output(Output::Change { + to: Default::default(), + amount: 0, + asset_id, + }) + .finalize_as_transaction(); + + let status = client.submit_and_await_commit(&tx).await.unwrap(); + assert!(matches!(status, TransactionStatus::Success { .. })); + + // Query asset info after burn + let final_supply = client + .asset_info(&asset_id) + .await + .unwrap() + .unwrap() + .total_supply + .0; + + // Verify burn was recorded + assert_eq!(final_supply, initial_supply - burn_amount); +} diff --git a/tests/tests/lib.rs b/tests/tests/lib.rs index c42218b045f..42b08f387b9 100644 --- a/tests/tests/lib.rs +++ b/tests/tests/lib.rs @@ -1,6 +1,8 @@ #![deny(unused_must_use)] #![deny(warnings)] +#[cfg(not(feature = "only-p2p"))] +mod assets; #[cfg(not(feature = "only-p2p"))] mod balances; #[cfg(not(feature = "only-p2p"))]