diff --git a/crates/torii/core/src/sql/erc.rs b/crates/torii/core/src/sql/erc.rs index 507e629b81..24dab974b2 100644 --- a/crates/torii/core/src/sql/erc.rs +++ b/crates/torii/core/src/sql/erc.rs @@ -148,6 +148,8 @@ impl Sql { .fetch_one(&self.pool) .await?; + // TODO(opt): we dont need to make rpc call for each token id for erc721, metadata for all + // tokens is same is same for a specific contract if !token_exists { register_erc721_token_metadata( contract_address, diff --git a/crates/torii/core/src/sql/utils.rs b/crates/torii/core/src/sql/utils.rs index 5a6b97b860..26476b0837 100644 --- a/crates/torii/core/src/sql/utils.rs +++ b/crates/torii/core/src/sql/utils.rs @@ -8,19 +8,19 @@ pub fn felts_to_sql_string(felts: &[Felt]) -> String { + FELT_DELIMITER } -pub(crate) fn felt_to_sql_string(felt: &Felt) -> String { +pub fn felt_to_sql_string(felt: &Felt) -> String { format!("{:#x}", felt) } -pub(crate) fn felt_and_u256_to_sql_string(felt: &Felt, u256: &U256) -> String { +pub fn felt_and_u256_to_sql_string(felt: &Felt, u256: &U256) -> String { format!("{}:{}", felt_to_sql_string(felt), u256_to_sql_string(u256)) } -pub(crate) fn u256_to_sql_string(u256: &U256) -> String { +pub fn u256_to_sql_string(u256: &U256) -> String { format!("{:#064x}", u256) } -pub(crate) fn sql_string_to_u256(sql_string: &str) -> U256 { +pub fn sql_string_to_u256(sql_string: &str) -> U256 { let sql_string = sql_string.strip_prefix("0x").unwrap_or(sql_string); U256::from(crypto_bigint::U256::from_be_hex(sql_string)) } diff --git a/crates/torii/graphql/Cargo.toml b/crates/torii/graphql/Cargo.toml index 464dddefb6..8c239ef9b4 100644 --- a/crates/torii/graphql/Cargo.toml +++ b/crates/torii/graphql/Cargo.toml @@ -26,6 +26,7 @@ serde.workspace = true serde_json.workspace = true sozo-ops.workspace = true sqlx.workspace = true +starknet-crypto.workspace = true strum.workspace = true strum_macros.workspace = true thiserror.workspace = true @@ -46,5 +47,4 @@ dojo-world.workspace = true katana-runner.workspace = true scarb.workspace = true serial_test = "2.0.0" -starknet-crypto.workspace = true starknet.workspace = true diff --git a/crates/torii/graphql/src/constants.rs b/crates/torii/graphql/src/constants.rs index e09c8de6d2..2d851f07b1 100644 --- a/crates/torii/graphql/src/constants.rs +++ b/crates/torii/graphql/src/constants.rs @@ -33,6 +33,9 @@ pub const QUERY_TYPE_NAME: &str = "World__Query"; pub const SUBSCRIPTION_TYPE_NAME: &str = "World__Subscription"; pub const MODEL_ORDER_TYPE_NAME: &str = "World__ModelOrder"; pub const MODEL_ORDER_FIELD_TYPE_NAME: &str = "World__ModelOrderField"; +pub const ERC_BALANCE_TYPE_NAME: &str = "ERC__Balance"; +pub const ERC_TRANSFER_TYPE_NAME: &str = "ERC__Transfer"; +pub const ERC_TOKEN_TYPE_NAME: &str = "ERC__Token"; // objects' single and plural names pub const ENTITY_NAMES: (&str, &str) = ("entity", "entities"); @@ -45,6 +48,10 @@ pub const METADATA_NAMES: (&str, &str) = ("metadata", "metadatas"); pub const TRANSACTION_NAMES: (&str, &str) = ("transaction", "transactions"); pub const PAGE_INFO_NAMES: (&str, &str) = ("pageInfo", ""); +pub const ERC_BALANCE_NAME: (&str, &str) = ("ercBalance", ""); +pub const ERC_TOKEN_NAME: (&str, &str) = ("ercToken", ""); +pub const ERC_TRANSFER_NAME: (&str, &str) = ("ercTransfer", ""); + // misc pub const ORDER_DIR_TYPE_NAME: &str = "OrderDirection"; pub const ORDER_ASC: &str = "ASC"; diff --git a/crates/torii/graphql/src/error.rs b/crates/torii/graphql/src/error.rs index d00969f98b..83834c9f35 100644 --- a/crates/torii/graphql/src/error.rs +++ b/crates/torii/graphql/src/error.rs @@ -9,6 +9,8 @@ pub enum ExtractError { NotList(String), #[error("Not a string: {0}")] NotString(String), + #[error("Not a felt: {0}")] + NotFelt(String), #[error("Not a number: {0}")] NotNumber(String), } diff --git a/crates/torii/graphql/src/mapping.rs b/crates/torii/graphql/src/mapping.rs index 1086373bca..47f7d8e1b1 100644 --- a/crates/torii/graphql/src/mapping.rs +++ b/crates/torii/graphql/src/mapping.rs @@ -4,7 +4,7 @@ use async_graphql::Name; use dojo_types::primitive::Primitive; use lazy_static::lazy_static; -use crate::constants::{CONTENT_TYPE_NAME, SOCIAL_TYPE_NAME}; +use crate::constants::{CONTENT_TYPE_NAME, ERC_TOKEN_TYPE_NAME, SOCIAL_TYPE_NAME}; use crate::types::{GraphqlType, TypeData, TypeMapping}; lazy_static! { @@ -144,4 +144,27 @@ lazy_static! { TypeData::Simple(TypeRef::named(GraphqlType::DateTime.to_string())) ), ]); + + pub static ref ERC_BALANCE_TYPE_MAPPING: TypeMapping = IndexMap::from([ + (Name::new("balance"), TypeData::Simple(TypeRef::named(TypeRef::STRING))), + (Name::new("type"), TypeData::Simple(TypeRef::named(TypeRef::STRING))), + (Name::new("token_metadata"), TypeData::Simple(TypeRef::named(ERC_TOKEN_TYPE_NAME))), + ]); + + pub static ref ERC_TRANSFER_TYPE_MAPPING: TypeMapping = IndexMap::from([ + (Name::new("from"), TypeData::Simple(TypeRef::named(TypeRef::STRING))), + (Name::new("to"), TypeData::Simple(TypeRef::named(TypeRef::STRING))), + (Name::new("amount"), TypeData::Simple(TypeRef::named(TypeRef::STRING))), + (Name::new("type"), TypeData::Simple(TypeRef::named(TypeRef::STRING))), + (Name::new("executed_at"), TypeData::Simple(TypeRef::named(TypeRef::STRING))), + (Name::new("token_metadata"), TypeData::Simple(TypeRef::named(ERC_TOKEN_TYPE_NAME))), + ]); + + pub static ref ERC_TOKEN_TYPE_MAPPING: TypeMapping = IndexMap::from([ + (Name::new("name"), TypeData::Simple(TypeRef::named(TypeRef::STRING))), + (Name::new("symbol"), TypeData::Simple(TypeRef::named(TypeRef::STRING))), + (Name::new("token_id"), TypeData::Simple(TypeRef::named(TypeRef::STRING))), + (Name::new("decimals"), TypeData::Simple(TypeRef::named(TypeRef::STRING))), + (Name::new("contract_address"), TypeData::Simple(TypeRef::named(TypeRef::STRING))), + ]); } diff --git a/crates/torii/graphql/src/object/erc/erc_balance.rs b/crates/torii/graphql/src/object/erc/erc_balance.rs new file mode 100644 index 0000000000..77cc492dc2 --- /dev/null +++ b/crates/torii/graphql/src/object/erc/erc_balance.rs @@ -0,0 +1,143 @@ +use async_graphql::dynamic::{Field, FieldFuture, InputValue, TypeRef}; +use async_graphql::{Name, Value}; +use convert_case::{Case, Casing}; +use serde::Deserialize; +use sqlx::{FromRow, Pool, Sqlite, SqliteConnection}; +use starknet_crypto::Felt; +use torii_core::sql::utils::felt_to_sql_string; +use tracing::warn; + +use crate::constants::{ERC_BALANCE_NAME, ERC_BALANCE_TYPE_NAME}; +use crate::mapping::ERC_BALANCE_TYPE_MAPPING; +use crate::object::{BasicObject, ResolvableObject}; +use crate::types::{TypeMapping, ValueMapping}; +use crate::utils::extract; + +#[derive(Debug)] +pub struct ErcBalanceObject; + +impl BasicObject for ErcBalanceObject { + fn name(&self) -> (&str, &str) { + ERC_BALANCE_NAME + } + + fn type_name(&self) -> &str { + ERC_BALANCE_TYPE_NAME + } + + fn type_mapping(&self) -> &TypeMapping { + &ERC_BALANCE_TYPE_MAPPING + } +} + +impl ResolvableObject for ErcBalanceObject { + fn resolvers(&self) -> Vec { + let account_address = "account_address"; + let argument = InputValue::new( + account_address.to_case(Case::Camel), + TypeRef::named_nn(TypeRef::STRING), + ); + + let field = Field::new(self.name().0, TypeRef::named_list(self.type_name()), move |ctx| { + FieldFuture::new(async move { + let mut conn = ctx.data::>()?.acquire().await?; + let address = extract::( + ctx.args.as_index_map(), + &account_address.to_case(Case::Camel), + )?; + + let erc_balances = fetch_erc_balances(&mut conn, address).await?; + + Ok(Some(Value::List(erc_balances))) + }) + }) + .argument(argument); + vec![field] + } +} + +async fn fetch_erc_balances( + conn: &mut SqliteConnection, + address: Felt, +) -> sqlx::Result> { + let query = "SELECT t.contract_address, t.name, t.symbol, t.decimals, b.balance, b.token_id, \ + c.contract_type + FROM balances b + JOIN tokens t ON b.token_id = t.id + JOIN contracts c ON t.contract_address = c.contract_address + WHERE b.account_address = ?"; + + let rows = sqlx::query(query).bind(felt_to_sql_string(&address)).fetch_all(conn).await?; + + let mut erc_balances = Vec::new(); + + for row in rows { + let row = BalanceQueryResultRaw::from_row(&row)?; + + let balance_value = match row.contract_type.as_str() { + "ERC20" | "Erc20" | "erc20" => { + let token_metadata = Value::Object(ValueMapping::from([ + (Name::new("name"), Value::String(row.name)), + (Name::new("symbol"), Value::String(row.symbol)), + // for erc20 there is no token_id + (Name::new("token_id"), Value::Null), + (Name::new("decimals"), Value::String(row.decimals.to_string())), + (Name::new("contract_address"), Value::String(row.contract_address.clone())), + ])); + + Value::Object(ValueMapping::from([ + (Name::new("balance"), Value::String(row.balance)), + (Name::new("type"), Value::String(row.contract_type)), + (Name::new("token_metadata"), token_metadata), + ])) + } + "ERC721" | "Erc721" | "erc721" => { + // contract_address:token_id + let token_id = row.token_id.split(':').collect::>(); + assert!(token_id.len() == 2); + + let token_metadata = Value::Object(ValueMapping::from([ + (Name::new("contract_address"), Value::String(row.contract_address.clone())), + (Name::new("name"), Value::String(row.name)), + (Name::new("symbol"), Value::String(row.symbol)), + (Name::new("token_id"), Value::String(row.token_id)), + (Name::new("decimals"), Value::String(row.decimals.to_string())), + ])); + + Value::Object(ValueMapping::from([ + (Name::new("balance"), Value::String(row.balance)), + (Name::new("type"), Value::String(row.contract_type)), + (Name::new("token_metadata"), token_metadata), + ])) + } + _ => { + warn!("Unknown contract type: {}", row.contract_type); + continue; + } + }; + + erc_balances.push(balance_value); + } + + Ok(erc_balances) +} + +// TODO: This would be required when subscriptions are needed +// impl ErcBalanceObject { +// pub fn value_mapping(entity: ErcBalance) -> ValueMapping { +// IndexMap::from([ +// ]) +// } +// } + +#[derive(FromRow, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +struct BalanceQueryResultRaw { + pub contract_address: String, + pub name: String, + pub symbol: String, + pub decimals: u8, + pub token_id: String, + pub balance: String, + pub contract_type: String, +} diff --git a/crates/torii/graphql/src/object/erc/erc_token.rs b/crates/torii/graphql/src/object/erc/erc_token.rs new file mode 100644 index 0000000000..14b8de7877 --- /dev/null +++ b/crates/torii/graphql/src/object/erc/erc_token.rs @@ -0,0 +1,21 @@ +use crate::constants::{ERC_TOKEN_NAME, ERC_TOKEN_TYPE_NAME}; +use crate::mapping::ERC_TOKEN_TYPE_MAPPING; +use crate::object::BasicObject; +use crate::types::TypeMapping; + +#[derive(Debug)] +pub struct ErcTokenObject; + +impl BasicObject for ErcTokenObject { + fn name(&self) -> (&str, &str) { + ERC_TOKEN_NAME + } + + fn type_name(&self) -> &str { + ERC_TOKEN_TYPE_NAME + } + + fn type_mapping(&self) -> &TypeMapping { + &ERC_TOKEN_TYPE_MAPPING + } +} diff --git a/crates/torii/graphql/src/object/erc/erc_transfer.rs b/crates/torii/graphql/src/object/erc/erc_transfer.rs new file mode 100644 index 0000000000..056f0c224b --- /dev/null +++ b/crates/torii/graphql/src/object/erc/erc_transfer.rs @@ -0,0 +1,181 @@ +use async_graphql::dynamic::{Field, FieldFuture, InputValue, TypeRef}; +use async_graphql::{Name, Value}; +use convert_case::{Case, Casing}; +use serde::Deserialize; +use sqlx::{FromRow, Pool, Sqlite, SqliteConnection}; +use starknet_crypto::Felt; +use torii_core::sql::utils::felt_to_sql_string; +use tracing::warn; + +use crate::constants::{ERC_TRANSFER_NAME, ERC_TRANSFER_TYPE_NAME}; +use crate::mapping::ERC_TRANSFER_TYPE_MAPPING; +use crate::object::{BasicObject, ResolvableObject}; +use crate::types::{TypeMapping, ValueMapping}; +use crate::utils::extract; + +#[derive(Debug)] +pub struct ErcTransferObject; + +impl BasicObject for ErcTransferObject { + fn name(&self) -> (&str, &str) { + ERC_TRANSFER_NAME + } + + fn type_name(&self) -> &str { + ERC_TRANSFER_TYPE_NAME + } + + fn type_mapping(&self) -> &TypeMapping { + &ERC_TRANSFER_TYPE_MAPPING + } +} + +impl ResolvableObject for ErcTransferObject { + fn resolvers(&self) -> Vec { + let account_address = "account_address"; + let limit = "limit"; + let arg_addr = InputValue::new( + account_address.to_case(Case::Camel), + TypeRef::named_nn(TypeRef::STRING), + ); + let arg_limit = + InputValue::new(limit.to_case(Case::Camel), TypeRef::named_nn(TypeRef::INT)); + + let field = Field::new(self.name().0, TypeRef::named_list(self.type_name()), move |ctx| { + FieldFuture::new(async move { + let mut conn = ctx.data::>()?.acquire().await?; + let address = extract::( + ctx.args.as_index_map(), + &account_address.to_case(Case::Camel), + )?; + let limit = extract::(ctx.args.as_index_map(), &limit.to_case(Case::Camel))?; + let limit: u32 = limit.try_into()?; + + let erc_transfers = fetch_erc_transfers(&mut conn, address, limit).await?; + + Ok(Some(Value::List(erc_transfers))) + }) + }) + .argument(arg_addr) + .argument(arg_limit); + vec![field] + } +} + +async fn fetch_erc_transfers( + conn: &mut SqliteConnection, + address: Felt, + limit: u32, +) -> sqlx::Result> { + let query = format!( + r#" +SELECT + et.contract_address, + et.from_address, + et.to_address, + et.amount, + et.token_id, + et.executed_at, + t.name, + t.symbol, + t.decimals, + c.contract_type +FROM + erc_transfers et +JOIN + tokens t ON et.token_id = t.id +JOIN + contracts c ON t.contract_address = c.contract_address +WHERE + et.from_address = ? OR et.to_address = ? +ORDER BY + et.executed_at DESC +LIMIT {}; +"#, + limit + ); + + let address = felt_to_sql_string(&address); + let rows = sqlx::query(&query).bind(&address).bind(&address).fetch_all(conn).await?; + + let mut erc_balances = Vec::new(); + + for row in rows { + let row = TransferQueryResultRaw::from_row(&row)?; + + let transfer_value = match row.contract_type.as_str() { + "ERC20" | "Erc20" | "erc20" => { + let token_metadata = Value::Object(ValueMapping::from([ + (Name::new("name"), Value::String(row.name)), + (Name::new("symbol"), Value::String(row.symbol)), + // for erc20 there is no token_id + (Name::new("token_id"), Value::Null), + (Name::new("decimals"), Value::String(row.decimals.to_string())), + (Name::new("contract_address"), Value::String(row.contract_address.clone())), + ])); + + Value::Object(ValueMapping::from([ + (Name::new("from"), Value::String(row.from_address)), + (Name::new("to"), Value::String(row.to_address)), + (Name::new("amount"), Value::String(row.amount)), + (Name::new("type"), Value::String(row.contract_type)), + (Name::new("executed_at"), Value::String(row.executed_at)), + (Name::new("token_metadata"), token_metadata), + ])) + } + "ERC721" | "Erc721" | "erc721" => { + // contract_address:token_id + let token_id = row.token_id.split(':').collect::>(); + assert!(token_id.len() == 2); + + let token_metadata = Value::Object(ValueMapping::from([ + (Name::new("name"), Value::String(row.name)), + (Name::new("symbol"), Value::String(row.symbol)), + (Name::new("token_id"), Value::String(token_id[1].to_string())), + (Name::new("decimals"), Value::String(row.decimals.to_string())), + (Name::new("contract_address"), Value::String(row.contract_address.clone())), + ])); + + Value::Object(ValueMapping::from([ + (Name::new("from"), Value::String(row.from_address)), + (Name::new("to"), Value::String(row.to_address)), + (Name::new("amount"), Value::String(row.amount)), + (Name::new("type"), Value::String(row.contract_type)), + (Name::new("executed_at"), Value::String(row.executed_at)), + (Name::new("token_metadata"), token_metadata), + ])) + } + _ => { + warn!("Unknown contract type: {}", row.contract_type); + continue; + } + }; + + erc_balances.push(transfer_value); + } + + Ok(erc_balances) +} + +// TODO: This would be required when subscriptions are needed +// impl ErcTransferObject { +// pub fn value_mapping(entity: ErcBalance) -> ValueMapping { +// IndexMap::from([ +// ]) +// } +// } + +#[derive(FromRow, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +struct TransferQueryResultRaw { + pub contract_address: String, + pub from_address: String, + pub to_address: String, + pub token_id: String, + pub amount: String, + pub executed_at: String, + pub name: String, + pub symbol: String, + pub decimals: u8, + pub contract_type: String, +} diff --git a/crates/torii/graphql/src/object/erc/mod.rs b/crates/torii/graphql/src/object/erc/mod.rs new file mode 100644 index 0000000000..eac2c5510b --- /dev/null +++ b/crates/torii/graphql/src/object/erc/mod.rs @@ -0,0 +1,3 @@ +pub mod erc_balance; +pub mod erc_token; +pub mod erc_transfer; diff --git a/crates/torii/graphql/src/object/mod.rs b/crates/torii/graphql/src/object/mod.rs index c1046ffbe4..8997cdabe3 100644 --- a/crates/torii/graphql/src/object/mod.rs +++ b/crates/torii/graphql/src/object/mod.rs @@ -1,5 +1,6 @@ pub mod connection; pub mod entity; +pub mod erc; pub mod event; pub mod event_message; pub mod inputs; diff --git a/crates/torii/graphql/src/schema.rs b/crates/torii/graphql/src/schema.rs index 48a915345b..5f70c49908 100644 --- a/crates/torii/graphql/src/schema.rs +++ b/crates/torii/graphql/src/schema.rs @@ -10,6 +10,9 @@ use super::object::model_data::ModelDataObject; use super::types::ScalarType; use super::utils; use crate::constants::{QUERY_TYPE_NAME, SUBSCRIPTION_TYPE_NAME}; +use crate::object::erc::erc_balance::ErcBalanceObject; +use crate::object::erc::erc_token::ErcTokenObject; +use crate::object::erc::erc_transfer::ErcTransferObject; use crate::object::event_message::EventMessageObject; use crate::object::metadata::content::ContentObject; use crate::object::metadata::social::SocialObject; @@ -28,6 +31,7 @@ pub async fn build_schema(pool: &SqlitePool) -> Result { let (objects, unions) = build_objects(pool).await?; let mut schema_builder = Schema::build(QUERY_TYPE_NAME, None, Some(SUBSCRIPTION_TYPE_NAME)); + //? why we need to provide QUERY_TYPE_NAME object here when its already passed to Schema? let mut query_root = Object::new(QUERY_TYPE_NAME); let mut subscription_root = Subscription::new(SUBSCRIPTION_TYPE_NAME); @@ -112,9 +116,12 @@ async fn build_objects(pool: &SqlitePool) -> Result<(Vec, Vec Result { .await .unwrap(); - TransactionWaiter::new(transaction_hash, &provider).await?; + TransactionWaiter::new(transaction_hash, &account.provider()).await?; // Execute `delete` and delete Record with id 20 let InvokeTransactionResult { transaction_hash } = account diff --git a/crates/torii/graphql/src/tests/subscription_test.rs b/crates/torii/graphql/src/tests/subscription_test.rs index ccb7dbf114..369dfa25d9 100644 --- a/crates/torii/graphql/src/tests/subscription_test.rs +++ b/crates/torii/graphql/src/tests/subscription_test.rs @@ -23,7 +23,7 @@ mod tests { #[sqlx::test(migrations = "../migrations")] #[serial] async fn test_entity_subscription(pool: SqlitePool) { - let mut db = Sql::new(pool.clone(), Felt::ZERO, &HashMap::new()).await.unwrap(); + let mut db = Sql::new(pool.clone(), Felt::ZERO, &HashMap::default()).await.unwrap(); model_fixtures(&mut db).await; // 0. Preprocess expected entity value diff --git a/crates/torii/graphql/src/utils.rs b/crates/torii/graphql/src/utils.rs index 8f49990d4a..949e3b9711 100644 --- a/crates/torii/graphql/src/utils.rs +++ b/crates/torii/graphql/src/utils.rs @@ -1,5 +1,8 @@ +use std::str::FromStr; + use async_graphql::{Result, Value}; use convert_case::{Case, Casing}; +use starknet_crypto::Felt; use crate::error::ExtractError; use crate::types::ValueMapping; @@ -28,6 +31,18 @@ impl ExtractFromIndexMap for String { } } +impl ExtractFromIndexMap for Felt { + fn extract(indexmap: &ValueMapping, input: &str) -> Result { + let value = indexmap.get(input).ok_or_else(|| ExtractError::NotFound(input.to_string()))?; + match value { + Value::String(s) => { + Ok(Felt::from_str(s).map_err(|_| ExtractError::NotFelt(input.to_string()))?) + } + _ => Err(ExtractError::NotString(input.to_string())), + } + } +} + impl ExtractFromIndexMap for Vec { fn extract(indexmap: &ValueMapping, input: &str) -> Result { let value = indexmap.get(input).ok_or_else(|| ExtractError::NotFound(input.to_string()))?;