diff --git a/.github/workflows/indexer.yml b/.github/workflows/indexer.yml new file mode 100644 index 0000000..837a3e6 --- /dev/null +++ b/.github/workflows/indexer.yml @@ -0,0 +1,33 @@ +name: Indexer + +on: + workflow_dispatch: {} + push: + branches: + - "main" + paths: + - ".github/workflows/indexer.yml" + - "indexer/*" + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + file: docker/Dockerfile.indexer + platforms: linux/amd64 + push: true + tags: ghcr.io/txpipe/asteria-indexer:${{ github.sha }} diff --git a/.gitignore b/.gitignore index 65b4e38..bed3abc 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ target # .env files **/.env + +.mono \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index d84b6c7..d605bcf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -237,6 +237,7 @@ dependencies = [ "async-graphql", "async-graphql-rocket", "dotenv", + "num-traits", "rocket", "sqlx", ] @@ -1287,6 +1288,17 @@ dependencies = [ "winit", ] +[[package]] +name = "bigdecimal" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6773ddc0eafc0e509fb60e48dff7f450f8e674a0686ae8605e8d9901bd5eefa" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "binascii" version = "0.1.4" @@ -3354,6 +3366,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "num-bigint" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "608e7659b5c3d7cba262d894801b9ec9d00de989e8a82bd4bef91d08da45cdc0" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-bigint-dig" version = "0.8.4" @@ -4458,6 +4481,7 @@ checksum = "24ba59a9342a3d9bab6c56c118be528b27c9b60e490080e9711a04dccac83ef6" dependencies = [ "ahash", "atoi", + "bigdecimal", "byteorder", "bytes", "crc", @@ -4536,6 +4560,7 @@ checksum = "1ed31390216d20e538e447a7a9b959e06ed9fc51c37b514b46eb758016ecd418" dependencies = [ "atoi", "base64", + "bigdecimal", "bitflags 2.5.0", "byteorder", "bytes", @@ -4578,6 +4603,7 @@ checksum = "7c824eb80b894f926f89a0b9da0c7f435d27cdd35b8c655b114e58223918577e" dependencies = [ "atoi", "base64", + "bigdecimal", "bitflags 2.5.0", "byteorder", "crc", @@ -4595,6 +4621,7 @@ dependencies = [ "log", "md-5", "memchr", + "num-bigint", "once_cell", "rand", "serde", diff --git a/backend/.sqlx/query-30baa48c0327a435e971472a5d92b4fc7b12298d1b5e6c8d02ead5be8ef6a255.json b/backend/.sqlx/query-30baa48c0327a435e971472a5d92b4fc7b12298d1b5e6c8d02ead5be8ef6a255.json new file mode 100644 index 0000000..f89e570 --- /dev/null +++ b/backend/.sqlx/query-30baa48c0327a435e971472a5d92b4fc7b12298d1b5e6c8d02ead5be8ef6a255.json @@ -0,0 +1,72 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, fuel, positionX as position_x, positionY as position_y, shipyardPolicy as policy_id, shipTokenName as token_name, pilotTokenName as pilot_name, class, totalRewards as total_rewards\n FROM mapobjects\n WHERE class = 'Fuel' AND shipyardPolicy = $1\n LIMIT $2 OFFSET $3", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "fuel", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "position_x", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "position_y", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "policy_id", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "token_name", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "pilot_name", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "class", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "total_rewards", + "type_info": "Numeric" + } + ], + "parameters": { + "Left": [ + "Text", + "Int8", + "Int8" + ] + }, + "nullable": [ + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + "hash": "30baa48c0327a435e971472a5d92b4fc7b12298d1b5e6c8d02ead5be8ef6a255" +} diff --git a/backend/.sqlx/query-3922696572bc7f08dc8f638576346e980059236eaafeb568d59db33927dc2478.json b/backend/.sqlx/query-3922696572bc7f08dc8f638576346e980059236eaafeb568d59db33927dc2478.json new file mode 100644 index 0000000..4e139c7 --- /dev/null +++ b/backend/.sqlx/query-3922696572bc7f08dc8f638576346e980059236eaafeb568d59db33927dc2478.json @@ -0,0 +1,72 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, fuel, positionX as position_x, positionY as position_y, shipyardPolicy as policy_id, shipTokenName as token_name, pilotTokenName as pilot_name, class, totalRewards as total_rewards\n FROM mapobjects\n WHERE class = 'Ship' AND shipyardPolicy = $1\n LIMIT $2 OFFSET $3", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "fuel", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "position_x", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "position_y", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "policy_id", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "token_name", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "pilot_name", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "class", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "total_rewards", + "type_info": "Numeric" + } + ], + "parameters": { + "Left": [ + "Text", + "Int8", + "Int8" + ] + }, + "nullable": [ + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + "hash": "3922696572bc7f08dc8f638576346e980059236eaafeb568d59db33927dc2478" +} diff --git a/backend/.sqlx/query-6ee078aa4ca0bd8567c784afd4258c2fa60acf4c07236f99ca3ce36e829cfe30.json b/backend/.sqlx/query-6ee078aa4ca0bd8567c784afd4258c2fa60acf4c07236f99ca3ce36e829cfe30.json new file mode 100644 index 0000000..0216b4a --- /dev/null +++ b/backend/.sqlx/query-6ee078aa4ca0bd8567c784afd4258c2fa60acf4c07236f99ca3ce36e829cfe30.json @@ -0,0 +1,70 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, fuel, positionX as position_x, positionY as position_y, shipyardPolicy as policy_id, shipTokenName as token_name, pilotTokenName as pilot_name, class, totalRewards as total_rewards\n FROM mapobjects\n WHERE class = 'Asteria' AND shipyardPolicy = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "fuel", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "position_x", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "position_y", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "policy_id", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "token_name", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "pilot_name", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "class", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "total_rewards", + "type_info": "Numeric" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + "hash": "6ee078aa4ca0bd8567c784afd4258c2fa60acf4c07236f99ca3ce36e829cfe30" +} diff --git a/backend/.sqlx/query-d234cba02f8f6fa0620368c1a6c164859c6a6efaade36ffa824a1bcbda537664.json b/backend/.sqlx/query-8e0687b38756dd37d17653b64009ed98f06326b13b521d1079a009a549c0ddc8.json similarity index 75% rename from backend/.sqlx/query-d234cba02f8f6fa0620368c1a6c164859c6a6efaade36ffa824a1bcbda537664.json rename to backend/.sqlx/query-8e0687b38756dd37d17653b64009ed98f06326b13b521d1079a009a549c0ddc8.json index 7fb9857..9a9ebf3 100644 --- a/backend/.sqlx/query-d234cba02f8f6fa0620368c1a6c164859c6a6efaade36ffa824a1bcbda537664.json +++ b/backend/.sqlx/query-8e0687b38756dd37d17653b64009ed98f06326b13b521d1079a009a549c0ddc8.json @@ -1,12 +1,12 @@ { "db_name": "PostgreSQL", - "query": "SELECT id, fuel, positionX as position_x, positionY as position_y, shipyardPolicy as policy_id, shipTokenName as token_name, pilotTokenName as pilot_name, class, totalRewards as total_rewards\n FROM MapObjects\n WHERE positionX BETWEEN ($1::int - $3::int) AND ($1::int + $3::int)\n AND positionY BETWEEN ($2::int - $3::int) AND ($2::int + $3::int)\n AND ABS(positionX - $1::int) + ABS(positionY - $2::int) <= $3::int", + "query": "SELECT id, fuel, positionX as position_x, positionY as position_y, shipyardPolicy as policy_id, shipTokenName as token_name, pilotTokenName as pilot_name, class, totalRewards as total_rewards\n FROM mapobjects\n WHERE positionX BETWEEN ($1::int - $3::int) AND ($1::int + $3::int)\n AND positionY BETWEEN ($2::int - $3::int) AND ($2::int + $3::int)\n AND ABS(positionX - $1::int) + ABS(positionY - $2::int) <= $3::int\n AND shipyardPolicy = $4::text", "describe": { "columns": [ { "ordinal": 0, "name": "id", - "type_info": "Varchar" + "type_info": "Text" }, { "ordinal": 1, @@ -31,42 +31,43 @@ { "ordinal": 5, "name": "token_name", - "type_info": "Varchar" + "type_info": "Text" }, { "ordinal": 6, "name": "pilot_name", - "type_info": "Varchar" + "type_info": "Text" }, { "ordinal": 7, "name": "class", - "type_info": "Varchar" + "type_info": "Text" }, { "ordinal": 8, "name": "total_rewards", - "type_info": "Int4" + "type_info": "Numeric" } ], "parameters": { "Left": [ "Int4", "Int4", - "Int4" + "Int4", + "Text" ] }, "nullable": [ - false, true, - false, - false, true, true, true, - false, + true, + true, + true, + true, true ] }, - "hash": "d234cba02f8f6fa0620368c1a6c164859c6a6efaade36ffa824a1bcbda537664" + "hash": "8e0687b38756dd37d17653b64009ed98f06326b13b521d1079a009a549c0ddc8" } diff --git a/backend/.sqlx/query-a6973f333350ca33a2cd7e10b2b94fa76276d21d9eb91675c9eea7b2ed68d690.json b/backend/.sqlx/query-a6973f333350ca33a2cd7e10b2b94fa76276d21d9eb91675c9eea7b2ed68d690.json new file mode 100644 index 0000000..68992af --- /dev/null +++ b/backend/.sqlx/query-a6973f333350ca33a2cd7e10b2b94fa76276d21d9eb91675c9eea7b2ed68d690.json @@ -0,0 +1,71 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, fuel, positionX as position_x, positionY as position_y, shipyardPolicy as policy_id, shipTokenName as token_name, pilotTokenName as pilot_name, class, totalRewards as total_rewards\n FROM mapobjects\n WHERE shipTokenName = $1 AND shipyardPolicy = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "fuel", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "position_x", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "position_y", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "policy_id", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "token_name", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "pilot_name", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "class", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "total_rewards", + "type_info": "Numeric" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + true, + true, + true, + true, + true, + true, + true, + true, + true + ] + }, + "hash": "a6973f333350ca33a2cd7e10b2b94fa76276d21d9eb91675c9eea7b2ed68d690" +} diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 673619c..8821215 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -9,5 +9,6 @@ edition = "2021" rocket = "0.5.0" async-graphql = "7.0.3" async-graphql-rocket = "7.0.3" -sqlx = { version = "0.7", features = [ "runtime-tokio", "postgres" ] } +sqlx = { version = "0.7", features = [ "runtime-tokio", "postgres", "bigdecimal"] } +num-traits = "0.2" dotenv = "0.15" \ No newline at end of file diff --git a/backend/src/main.rs b/backend/src/main.rs index 45d98e6..2940806 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -1,6 +1,9 @@ use async_graphql::http::GraphiQLSource; use dotenv::dotenv; +use num_traits::cast::ToPrimitive; +use num_traits::Zero; use rocket::response::content::RawHtml; +use sqlx::types::BigDecimal; use std::env; use std::ops::Deref; use std::vec; @@ -24,7 +27,7 @@ pub struct Ship { } #[derive(Clone, SimpleObject)] -pub struct FuelPellet { +pub struct Fuel { id: ID, fuel: i32, position: Position, @@ -33,10 +36,10 @@ pub struct FuelPellet { } #[derive(Clone, SimpleObject)] -pub struct RewardPot { +pub struct Asteria { id: ID, position: Position, - total_rewards: i32, + total_rewards: i64, class: String, } @@ -44,6 +47,7 @@ pub struct RewardPot { pub struct AsteriaState { ship_counter: i32, shipyard_policy: PolicyId, + reward: i64, } #[derive(Clone, SimpleObject)] @@ -91,8 +95,8 @@ pub enum ShipActionType { #[derive(Union)] pub enum MapObject { Ship(Ship), - FuelPellet(FuelPellet), - RewardPot(RewardPot), + Fuel(Fuel), + Asteria(Asteria), } #[derive(Interface)] @@ -103,122 +107,212 @@ pub enum MapObject { pub enum PositionalInterface { Ship(Ship), - FuelPellet(FuelPellet), - RewardPot(RewardPot), + Fuel(Fuel), + Asteria(Asteria), } +#[derive(Debug)] struct MapObjectRecord { - id: String, + id: Option, fuel: Option, - position_x: i32, - position_y: i32, + position_x: Option, + position_y: Option, policy_id: Option, token_name: Option, pilot_name: Option, class: Option, - total_rewards: Option, + total_rewards: Option, } #[Object] impl QueryRoot { - async fn ship(&self, _ctx: &Context<'_>, ship_token_name: AssetNameInput) -> Ship { - Ship { - id: ID::from("ship-1234"), - fuel: 100, - position: Position { x: 10, y: 20 }, - shipyard_policy: PolicyId { - id: ID::from("policy-5678"), - }, - ship_token_name: AssetName { - name: ship_token_name.name.clone(), - }, - pilot_token_name: AssetName { - name: "PilotOne".to_string(), - }, - class: "Ship".to_string(), + async fn ship(&self, ctx: &Context<'_>, ship_token_name: AssetNameInput) -> Result { + // Access the connection pool from the GraphQL context + let pool = ctx + .data::() + .map_err(|e| Error::new(e.message))?; + + let shipyard_policy_id = ctx + .data::() + .map_err(|e| Error::new(e.message))?; + + // Query to select a ship by token name + let fetched_ship = sqlx::query_as!(MapObjectRecord, + "SELECT id, fuel, positionX as position_x, positionY as position_y, shipyardPolicy as policy_id, shipTokenName as token_name, pilotTokenName as pilot_name, class, totalRewards as total_rewards + FROM mapobjects + WHERE shipTokenName = $1 AND shipyardPolicy = $2", + ship_token_name.name, shipyard_policy_id.id.to_string() + ); + + let record = fetched_ship + .fetch_one(pool) + .await + .map_err(|e| Error::new(e.to_string()))?; + + match record.class.as_deref() { + Some("Ship") => Ok(Ship { + id: ID::from(record.id.unwrap_or_default()), + fuel: record.fuel.unwrap_or(0), + position: Position { + x: record.position_x.unwrap_or(0), + y: record.position_y.unwrap_or(0), + }, + shipyard_policy: PolicyId { + id: ID::from(record.policy_id.unwrap_or_default()), + }, + ship_token_name: AssetName { + name: record.token_name.unwrap_or_default(), + }, + pilot_token_name: AssetName { + name: record.pilot_name.unwrap_or_default(), + }, + class: record.class.unwrap_or_default(), + }), + _ => panic!("Unknown class type or class not provided"), } } async fn ships( &self, - _ctx: &Context<'_>, + ctx: &Context<'_>, limit: Option, offset: Option, - ) -> Vec { + ) -> Result> { + // Access the connection pool from the GraphQL context + let pool = ctx + .data::() + .map_err(|e| Error::new(e.message))?; + + let shipyard_policy_id = ctx + .data::() + .map_err(|e| Error::new(e.message))?; + let mut ships = Vec::new(); - let num_ships = limit.unwrap_or(10); - let start = offset.unwrap_or(0) as usize; - // Generate fake data for ships - for i in start..(start + num_ships as usize) { + let num_ships = limit.unwrap_or(10) as i64; + + let start = offset.unwrap_or(0) as i64; + + // Query to select ships + let fetched_ships = sqlx::query_as!(MapObjectRecord, + "SELECT id, fuel, positionX as position_x, positionY as position_y, shipyardPolicy as policy_id, shipTokenName as token_name, pilotTokenName as pilot_name, class, totalRewards as total_rewards + FROM mapobjects + WHERE class = 'Ship' AND shipyardPolicy = $1 + LIMIT $2 OFFSET $3", + shipyard_policy_id.id.to_string(), num_ships, start + ) + .fetch_all(pool) + .await + .map_err(|e| Error::new(e.to_string()))?; + + for record in fetched_ships { ships.push(Ship { - id: ID::from(format!("ship-{}", i)), - fuel: 100, + id: ID::from(record.id.unwrap_or_default()), + fuel: record.fuel.unwrap_or(0), position: Position { - x: i as i32 * 10, - y: i as i32 * 20, + x: record.position_x.unwrap_or(0), + y: record.position_y.unwrap_or(0), }, shipyard_policy: PolicyId { - id: ID::from(format!("policy-{}", i)), + id: ID::from(record.policy_id.unwrap_or_default()), }, ship_token_name: AssetName { - name: format!("Explorer-{}", i), + name: record.token_name.unwrap_or_default(), }, pilot_token_name: AssetName { - name: format!("Pilot-{}", i), + name: record.pilot_name.unwrap_or_default(), }, - class: "Battleship".to_string(), + class: record.class.unwrap_or_default(), }); } - ships + Ok(ships) + } async fn fuel_pellets( &self, - _ctx: &Context<'_>, + ctx: &Context<'_>, limit: Option, offset: Option, - ) -> Vec { - let mut fuel_pellets = Vec::new(); - let num_pellets = limit.unwrap_or(10); - let start = offset.unwrap_or(0) as usize; + ) -> Result> { + // Access the connection pool from the GraphQL context + let pool = ctx + .data::() + .map_err(|e| Error::new(e.message))?; + + let shipyard_policy_id = ctx + .data::() + .map_err(|e| Error::new(e.message))?; + + let mut fuels = Vec::new(); + + let num_fuels = limit.unwrap_or(10) as i64; - // Generate fake data for fuel pellets - for i in start..(start + num_pellets as usize) { - fuel_pellets.push(FuelPellet { - id: ID::from(format!("pellet-{}", i)), - fuel: 100, + let start = offset.unwrap_or(0) as i64; + + // Query to select fuel pellets + let fetched_fuels = sqlx::query_as!(MapObjectRecord, + "SELECT id, fuel, positionX as position_x, positionY as position_y, shipyardPolicy as policy_id, shipTokenName as token_name, pilotTokenName as pilot_name, class, totalRewards as total_rewards + FROM mapobjects + WHERE class = 'Fuel' AND shipyardPolicy = $1 + LIMIT $2 OFFSET $3", + shipyard_policy_id.id.to_string(), num_fuels, start + ) + .fetch_all(pool) + .await + .map_err(|e| Error::new(e.to_string()))?; + + for record in fetched_fuels { + fuels.push(Fuel { + id: ID::from(record.id.unwrap_or_default()), + fuel: record.fuel.unwrap_or(0), position: Position { - x: i as i32 * 5, - y: i as i32 * 10, + x: record.position_x.unwrap_or(0), + y: record.position_y.unwrap_or(0), }, shipyard_policy: PolicyId { - id: ID::from(format!("policy-{}", i)), + id: ID::from(record.policy_id.unwrap_or_default()), }, - class: "Standard".to_string(), + class: record.class.unwrap_or_default(), }); } - fuel_pellets + Ok(fuels) } - async fn asteria_state(&self, _ctx: &Context<'_>) -> AsteriaState { - AsteriaState { - ship_counter: 123, + async fn asteria(&self, ctx: &Context<'_>) -> Result { + // Access the connection pool from the GraphQL context + let pool = ctx + .data::() + .map_err(|e| Error::new(e.message))?; + + let shipyard_policy_id = ctx + .data::() + .map_err(|e| Error::new(e.message))?; + + // Query to select the number of ships and the shipyard policy + let fetched_asteria_state = sqlx::query_as!(MapObjectRecord, + "SELECT id, fuel, positionX as position_x, positionY as position_y, shipyardPolicy as policy_id, shipTokenName as token_name, pilotTokenName as pilot_name, class, totalRewards as total_rewards + FROM mapobjects + WHERE class = 'Asteria' AND shipyardPolicy = $1", + shipyard_policy_id.id.to_string() + ) + .fetch_one(pool) + .await + .map_err(|e| Error::new(e.to_string()))?; + + Ok(AsteriaState { + ship_counter: fetched_asteria_state.fuel.unwrap_or(0), shipyard_policy: PolicyId { - id: ID::from("some-policy-id"), + id: ID::from(fetched_asteria_state.policy_id.unwrap_or_default()), }, - } - } - - async fn reward_pot(&self, _ctx: &Context<'_>) -> RewardPot { - RewardPot { - id: ID::from("reward-123"), - position: Position { x: 100, y: 200 }, - total_rewards: 5000, - class: "Standard".to_string(), - } + reward: fetched_asteria_state + .total_rewards + .unwrap_or(BigDecimal::zero()) + .to_i64() + .unwrap(), + }) } async fn objects_in_radius( @@ -232,28 +326,32 @@ impl QueryRoot { .data::() .map_err(|e| Error::new(e.message))?; + let shipyard_policy_id = ctx + .data::() + .map_err(|e| Error::new(e.message))?; + // Query to select map objects within a radius using Manhattan distance let fetched_objects = sqlx::query_as!(MapObjectRecord, "SELECT id, fuel, positionX as position_x, positionY as position_y, shipyardPolicy as policy_id, shipTokenName as token_name, pilotTokenName as pilot_name, class, totalRewards as total_rewards - FROM MapObjects + FROM mapobjects WHERE positionX BETWEEN ($1::int - $3::int) AND ($1::int + $3::int) AND positionY BETWEEN ($2::int - $3::int) AND ($2::int + $3::int) - AND ABS(positionX - $1::int) + ABS(positionY - $2::int) <= $3::int", - center.x, center.y, radius + AND ABS(positionX - $1::int) + ABS(positionY - $2::int) <= $3::int + AND shipyardPolicy = $4::text", + center.x, center.y, radius, shipyard_policy_id.id.to_string() ) .fetch_all(pool) .await .map_err(|e| Error::new(e.to_string()))?; - let map_objects: Vec = fetched_objects .into_iter() .map(|record| match record.class.as_deref() { Some("Ship") => MapObject::Ship(Ship { - id: ID::from(record.id), + id: ID::from(record.id.unwrap_or_default()), fuel: record.fuel.unwrap_or(0), position: Position { - x: record.position_x, - y: record.position_y, + x: record.position_x.unwrap_or(0), + y: record.position_y.unwrap_or(0), }, shipyard_policy: PolicyId { id: ID::from(record.policy_id.unwrap_or_default()), @@ -266,25 +364,29 @@ impl QueryRoot { }, class: record.class.unwrap_or_default(), }), - Some("FuelPellet") => MapObject::FuelPellet(FuelPellet { - id: ID::from(record.id), + Some("Fuel") => MapObject::Fuel(Fuel { + id: ID::from(record.id.unwrap_or_default()), fuel: record.fuel.unwrap_or(0), position: Position { - x: record.position_x, - y: record.position_y, + x: record.position_x.unwrap_or(0), + y: record.position_y.unwrap_or(0), }, shipyard_policy: PolicyId { id: ID::from(record.policy_id.unwrap_or_default()), }, class: record.class.unwrap_or_default(), }), - Some("RewardPot") => MapObject::RewardPot(RewardPot { - id: ID::from(record.id), + Some("Asteria") => MapObject::Asteria(Asteria { + id: ID::from(record.id.unwrap_or_default()), position: Position { - x: record.position_x, - y: record.position_y, + x: record.position_x.unwrap_or(0), + y: record.position_y.unwrap_or(0), }, - total_rewards: record.total_rewards.unwrap_or(0), + total_rewards: record + .total_rewards + .unwrap_or(BigDecimal::zero()) + .to_i64() + .unwrap(), class: record.class.unwrap_or_default(), }), _ => panic!("Unknown class type or class not provided"), @@ -381,6 +483,13 @@ async fn rocket() -> _ { let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set in the environment"); + let shipyard_policy_id = + env::var("SHIPYARD_POLICY_ID").expect("SHIPYARD_POLICY_ID must be set in the environment"); + + let shipyard_policy_id = PolicyId { + id: ID::from(shipyard_policy_id), + }; + let pool = sqlx::postgres::PgPoolOptions::new() .max_connections(5) .connect(database_url.as_str()) @@ -390,6 +499,7 @@ async fn rocket() -> _ { let schema = Schema::build(QueryRoot, EmptyMutation, EmptySubscription) .register_output_type::() .data(pool.clone()) + .data(shipyard_policy_id) .finish(); rocket::build() diff --git a/docker/Dockerfile.indexer b/docker/Dockerfile.indexer new file mode 100644 index 0000000..d43931c --- /dev/null +++ b/docker/Dockerfile.indexer @@ -0,0 +1,20 @@ +# Use the official Microsoft .NET runtime as a parent image +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base +WORKDIR /app + +# Use SDK image to build the application +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /app +COPY ./indexer ./ +RUN dotnet restore +RUN dotnet build -c Release -o /app/build + +# Publish the application +FROM build AS publish +RUN dotnet publish -c Release -o /app/publish + +# Final stage/image +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "Asteria.Indexer.dll"] \ No newline at end of file diff --git a/indexer/.gitignore b/indexer/.gitignore new file mode 100644 index 0000000..16562eb --- /dev/null +++ b/indexer/.gitignore @@ -0,0 +1,5 @@ +bin +obj +appsettings.json +appsettings.*.json +.mono \ No newline at end of file diff --git a/indexer/Asteria.Indexer.csproj b/indexer/Asteria.Indexer.csproj new file mode 100644 index 0000000..013c880 --- /dev/null +++ b/indexer/Asteria.Indexer.csproj @@ -0,0 +1,20 @@ + + + + net8.0 + enable + enable + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + diff --git a/indexer/Asteria.indexer.sln b/indexer/Asteria.indexer.sln new file mode 100644 index 0000000..6832f6c --- /dev/null +++ b/indexer/Asteria.indexer.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.002.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Asteria.Indexer", "Asteria.Indexer.csproj", "{BA2A87F7-E916-423A-93D8-02CF111CAA6C}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {BA2A87F7-E916-423A-93D8-02CF111CAA6C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BA2A87F7-E916-423A-93D8-02CF111CAA6C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BA2A87F7-E916-423A-93D8-02CF111CAA6C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BA2A87F7-E916-423A-93D8-02CF111CAA6C}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {E1D7CBF9-D560-427C-97F0-8459F474C6DE} + EndGlobalSection +EndGlobal diff --git a/indexer/Data/AsteriaDbContext.cs b/indexer/Data/AsteriaDbContext.cs new file mode 100644 index 0000000..481002f --- /dev/null +++ b/indexer/Data/AsteriaDbContext.cs @@ -0,0 +1,20 @@ +using Microsoft.EntityFrameworkCore; +using Cardano.Sync.Data; +using Asteria.Indexer.Data.Models; + +namespace Asteria.Indexer.Data; + +public class AsteriaDbContext +( + DbContextOptions options, + IConfiguration configuration +) : CardanoDbContext(options, configuration) +{ + public DbSet UtxoCborByAddresses { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().HasKey(x => new { x.TxHash, x.OutputIndex, x.Slot, x.Address }); + base.OnModelCreating(modelBuilder); + } +} \ No newline at end of file diff --git a/indexer/Data/Models/UtxoCborByAddress.cs b/indexer/Data/Models/UtxoCborByAddress.cs new file mode 100644 index 0000000..49d6ccd --- /dev/null +++ b/indexer/Data/Models/UtxoCborByAddress.cs @@ -0,0 +1,11 @@ +namespace Asteria.Indexer.Data.Models; + +public record UtxoCborByAddress +( + string TxHash, + ulong OutputIndex, + ulong Slot, + string Address, + ushort Era, + byte[] Cbor +); \ No newline at end of file diff --git a/indexer/Migrations/20240421223425_InitialCreate.Designer.cs b/indexer/Migrations/20240421223425_InitialCreate.Designer.cs new file mode 100644 index 0000000..4be4542 --- /dev/null +++ b/indexer/Migrations/20240421223425_InitialCreate.Designer.cs @@ -0,0 +1,167 @@ +// +using System.Text.Json; +using Asteria.Indexer.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Asteria.Indexer.Migrations +{ + [DbContext(typeof(AsteriaDbContext))] + [Migration("20240421223425_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("public") + .HasAnnotation("ProductVersion", "8.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Asteria.Indexer.Data.Models.UtxoCborByAddress", b => + { + b.Property("TxHash") + .HasColumnType("text"); + + b.Property("OutputIndex") + .HasColumnType("numeric(20,0)"); + + b.Property("Slot") + .HasColumnType("numeric(20,0)"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("Cbor") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("Era") + .HasColumnType("integer"); + + b.HasKey("TxHash", "OutputIndex", "Slot", "Address"); + + b.ToTable("UtxoCborByAddresses", "public"); + }); + + modelBuilder.Entity("Cardano.Sync.Data.Models.Block", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Number") + .HasColumnType("numeric(20,0)"); + + b.Property("Slot") + .HasColumnType("numeric(20,0)"); + + b.HasKey("Id", "Number", "Slot"); + + b.HasIndex("Slot"); + + b.ToTable("Blocks", "public"); + }); + + modelBuilder.Entity("Cardano.Sync.Data.Models.ReducerState", b => + { + b.Property("Name") + .HasColumnType("text"); + + b.Property("Hash") + .IsRequired() + .HasColumnType("text"); + + b.Property("Slot") + .HasColumnType("numeric(20,0)"); + + b.HasKey("Name"); + + b.ToTable("ReducerStates", "public"); + }); + + modelBuilder.Entity("Cardano.Sync.Data.Models.TransactionOutput", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Index") + .HasColumnType("bigint"); + + b.Property("Address") + .IsRequired() + .HasColumnType("text"); + + b.Property("Slot") + .HasColumnType("numeric(20,0)"); + + b.HasKey("Id", "Index"); + + b.HasIndex("Slot"); + + b.ToTable("TransactionOutputs", "public"); + }); + + modelBuilder.Entity("Cardano.Sync.Data.Models.TransactionOutput", b => + { + b.OwnsOne("Cardano.Sync.Data.Models.Datum", "Datum", b1 => + { + b1.Property("TransactionOutputId") + .HasColumnType("text"); + + b1.Property("TransactionOutputIndex") + .HasColumnType("bigint"); + + b1.Property("Data") + .IsRequired() + .HasColumnType("bytea"); + + b1.Property("Type") + .HasColumnType("integer"); + + b1.HasKey("TransactionOutputId", "TransactionOutputIndex"); + + b1.ToTable("TransactionOutputs", "public"); + + b1.WithOwner() + .HasForeignKey("TransactionOutputId", "TransactionOutputIndex"); + }); + + b.OwnsOne("Cardano.Sync.Data.Models.Value", "Amount", b1 => + { + b1.Property("TransactionOutputId") + .HasColumnType("text"); + + b1.Property("TransactionOutputIndex") + .HasColumnType("bigint"); + + b1.Property("Coin") + .HasColumnType("numeric(20,0)"); + + b1.Property("MultiAssetJson") + .HasColumnType("jsonb"); + + b1.HasKey("TransactionOutputId", "TransactionOutputIndex"); + + b1.ToTable("TransactionOutputs", "public"); + + b1.WithOwner() + .HasForeignKey("TransactionOutputId", "TransactionOutputIndex"); + }); + + b.Navigation("Amount") + .IsRequired(); + + b.Navigation("Datum"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/indexer/Migrations/20240421223425_InitialCreate.cs b/indexer/Migrations/20240421223425_InitialCreate.cs new file mode 100644 index 0000000..a4b6e99 --- /dev/null +++ b/indexer/Migrations/20240421223425_InitialCreate.cs @@ -0,0 +1,114 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Asteria.Indexer.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "public"); + + migrationBuilder.CreateTable( + name: "Blocks", + schema: "public", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + Number = table.Column(type: "numeric(20,0)", nullable: false), + Slot = table.Column(type: "numeric(20,0)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Blocks", x => new { x.Id, x.Number, x.Slot }); + }); + + migrationBuilder.CreateTable( + name: "ReducerStates", + schema: "public", + columns: table => new + { + Name = table.Column(type: "text", nullable: false), + Slot = table.Column(type: "numeric(20,0)", nullable: false), + Hash = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ReducerStates", x => x.Name); + }); + + migrationBuilder.CreateTable( + name: "TransactionOutputs", + schema: "public", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + Index = table.Column(type: "bigint", nullable: false), + Address = table.Column(type: "text", nullable: false), + Amount_Coin = table.Column(type: "numeric(20,0)", nullable: false), + Amount_MultiAssetJson = table.Column(type: "jsonb", nullable: false), + Datum_Type = table.Column(type: "integer", nullable: true), + Datum_Data = table.Column(type: "bytea", nullable: true), + Slot = table.Column(type: "numeric(20,0)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TransactionOutputs", x => new { x.Id, x.Index }); + }); + + migrationBuilder.CreateTable( + name: "UtxoCborByAddresses", + schema: "public", + columns: table => new + { + TxHash = table.Column(type: "text", nullable: false), + OutputIndex = table.Column(type: "numeric(20,0)", nullable: false), + Slot = table.Column(type: "numeric(20,0)", nullable: false), + Address = table.Column(type: "text", nullable: false), + Era = table.Column(type: "integer", nullable: false), + Cbor = table.Column(type: "bytea", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UtxoCborByAddresses", x => new { x.TxHash, x.OutputIndex, x.Slot, x.Address }); + }); + + migrationBuilder.CreateIndex( + name: "IX_Blocks_Slot", + schema: "public", + table: "Blocks", + column: "Slot"); + + migrationBuilder.CreateIndex( + name: "IX_TransactionOutputs_Slot", + schema: "public", + table: "TransactionOutputs", + column: "Slot"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "Blocks", + schema: "public"); + + migrationBuilder.DropTable( + name: "ReducerStates", + schema: "public"); + + migrationBuilder.DropTable( + name: "TransactionOutputs", + schema: "public"); + + migrationBuilder.DropTable( + name: "UtxoCborByAddresses", + schema: "public"); + } + } +} diff --git a/indexer/Migrations/AsteriaDbContextModelSnapshot.cs b/indexer/Migrations/AsteriaDbContextModelSnapshot.cs new file mode 100644 index 0000000..1abeebc --- /dev/null +++ b/indexer/Migrations/AsteriaDbContextModelSnapshot.cs @@ -0,0 +1,164 @@ +// +using System.Text.Json; +using Asteria.Indexer.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Asteria.Indexer.Migrations +{ + [DbContext(typeof(AsteriaDbContext))] + partial class AsteriaDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("public") + .HasAnnotation("ProductVersion", "8.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Asteria.Indexer.Data.Models.UtxoCborByAddress", b => + { + b.Property("TxHash") + .HasColumnType("text"); + + b.Property("OutputIndex") + .HasColumnType("numeric(20,0)"); + + b.Property("Slot") + .HasColumnType("numeric(20,0)"); + + b.Property("Address") + .HasColumnType("text"); + + b.Property("Cbor") + .IsRequired() + .HasColumnType("bytea"); + + b.Property("Era") + .HasColumnType("integer"); + + b.HasKey("TxHash", "OutputIndex", "Slot", "Address"); + + b.ToTable("UtxoCborByAddresses", "public"); + }); + + modelBuilder.Entity("Cardano.Sync.Data.Models.Block", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Number") + .HasColumnType("numeric(20,0)"); + + b.Property("Slot") + .HasColumnType("numeric(20,0)"); + + b.HasKey("Id", "Number", "Slot"); + + b.HasIndex("Slot"); + + b.ToTable("Blocks", "public"); + }); + + modelBuilder.Entity("Cardano.Sync.Data.Models.ReducerState", b => + { + b.Property("Name") + .HasColumnType("text"); + + b.Property("Hash") + .IsRequired() + .HasColumnType("text"); + + b.Property("Slot") + .HasColumnType("numeric(20,0)"); + + b.HasKey("Name"); + + b.ToTable("ReducerStates", "public"); + }); + + modelBuilder.Entity("Cardano.Sync.Data.Models.TransactionOutput", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("Index") + .HasColumnType("bigint"); + + b.Property("Address") + .IsRequired() + .HasColumnType("text"); + + b.Property("Slot") + .HasColumnType("numeric(20,0)"); + + b.HasKey("Id", "Index"); + + b.HasIndex("Slot"); + + b.ToTable("TransactionOutputs", "public"); + }); + + modelBuilder.Entity("Cardano.Sync.Data.Models.TransactionOutput", b => + { + b.OwnsOne("Cardano.Sync.Data.Models.Datum", "Datum", b1 => + { + b1.Property("TransactionOutputId") + .HasColumnType("text"); + + b1.Property("TransactionOutputIndex") + .HasColumnType("bigint"); + + b1.Property("Data") + .IsRequired() + .HasColumnType("bytea"); + + b1.Property("Type") + .HasColumnType("integer"); + + b1.HasKey("TransactionOutputId", "TransactionOutputIndex"); + + b1.ToTable("TransactionOutputs", "public"); + + b1.WithOwner() + .HasForeignKey("TransactionOutputId", "TransactionOutputIndex"); + }); + + b.OwnsOne("Cardano.Sync.Data.Models.Value", "Amount", b1 => + { + b1.Property("TransactionOutputId") + .HasColumnType("text"); + + b1.Property("TransactionOutputIndex") + .HasColumnType("bigint"); + + b1.Property("Coin") + .HasColumnType("numeric(20,0)"); + + b1.Property("MultiAssetJson") + .HasColumnType("jsonb"); + + b1.HasKey("TransactionOutputId", "TransactionOutputIndex"); + + b1.ToTable("TransactionOutputs", "public"); + + b1.WithOwner() + .HasForeignKey("TransactionOutputId", "TransactionOutputIndex"); + }); + + b.Navigation("Amount") + .IsRequired(); + + b.Navigation("Datum"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/indexer/Program.cs b/indexer/Program.cs new file mode 100644 index 0000000..4981eb7 --- /dev/null +++ b/indexer/Program.cs @@ -0,0 +1,116 @@ +using System.Text.Json; +using Asteria.Indexer.Data; +using Asteria.Indexer.Reduers; +using Cardano.Sync; +using Cardano.Sync.Reducers; +using Microsoft.EntityFrameworkCore; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +// Learn more about configuring Swagger/OpenAPI at https://aka.ms/aspnetcore/swashbuckle +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(); + +builder.Services.AddSingleton(); + +builder.Services.AddCardanoIndexer(builder.Configuration, 60); + +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +using var scope = app.Services.CreateScope(); +var dbContext = scope.ServiceProvider.GetRequiredService(); +dbContext.Database.Migrate(); + +var trackedAddressed = JsonSerializer.Deserialize(builder.Configuration.GetValue("UtxoAddresses") ?? "[]"); + +// SQL to create the materialized view + +var dropViewSql = "DROP MATERIALIZED VIEW IF EXISTS mapobjects;"; +dbContext.Database.ExecuteSqlRaw(dropViewSql); + +var createMaterializedViewSql = @$" +CREATE MATERIALIZED VIEW mapobjects AS +select + ""TxHash"" || '#' || ""OutputIndex"" as id, + 'Ship' as class, + CAST(utxo_plutus_data(""Era"", ""Cbor"") -> 'fields' -> 0 ->> 'int' AS INTEGER) AS fuel, + CAST(utxo_plutus_data(""Era"", ""Cbor"") -> 'fields' -> 1 ->> 'int' AS INTEGER) AS positionx, + CAST(utxo_plutus_data(""Era"", ""Cbor"") -> 'fields' -> 2 ->> 'int' AS INTEGER) AS positiony, + CAST(utxo_plutus_data(""Era"", ""Cbor"") -> 'fields' -> 3 ->> 'bytes' AS VARCHAR(56)) AS shipyardpolicy, + CAST(utxo_plutus_data(""Era"", ""Cbor"") -> 'fields' -> 4 ->> 'bytes' AS TEXT) AS shiptokenname, + CAST(utxo_plutus_data(""Era"", ""Cbor"") -> 'fields' -> 5 ->> 'bytes' AS TEXT) AS pilottokenname, + 0 as totalrewards +from ""UtxoCborByAddresses"" +where ""Address"" = '{trackedAddressed?[0]}' +union all +select + ""TxHash"" || '#' || ""OutputIndex"" as id, + 'Fuel' as class, + CAST(utxo_plutus_data(""Era"", ""Cbor"") -> 'fields' -> 0 ->> 'int' AS INTEGER) AS fuel, + CAST(utxo_plutus_data(""Era"", ""Cbor"") -> 'fields' -> 1 ->> 'int' AS INTEGER) AS positionx, + CAST(utxo_plutus_data(""Era"", ""Cbor"") -> 'fields' -> 2 ->> 'int' AS INTEGER) AS positiony, + CAST(utxo_plutus_data(""Era"", ""Cbor"") -> 'fields' -> 3 ->> 'bytes' AS VARCHAR(56)) AS shipyardpolicy, + NULL AS shiptokenname, + NULL AS pilottokenname, + 0 as totalrewards +from ""UtxoCborByAddresses"" +where ""Address"" = '{trackedAddressed?[1]}' +union all +select + ""TxHash"" || '#' || ""OutputIndex"" as id, + 'Asteria' as class, + 0 AS fuel, + 0 AS positionx, + 0 AS positiony, + CAST(utxo_plutus_data(""Era"", ""Cbor"") -> 'fields' -> 1 ->> 'bytes' AS VARCHAR(56)) AS shipyardpolicy, + NULL AS shiptokenname, + NULL AS pilottokenname, + utxo_lovelace(""Era"", ""Cbor"") as totalrewards +from ""UtxoCborByAddresses"" +where ""Address"" = '{trackedAddressed?[2]}'; +"; + +dbContext.Database.ExecuteSqlRaw(createMaterializedViewSql); + +var createIndexSql = "CREATE UNIQUE INDEX ON mapobjects (id);"; +dbContext.Database.ExecuteSqlRaw(createIndexSql); + +var createIndexPositionXSql = "CREATE INDEX idx_positionx ON mapobjects (positionx);"; +var createIndexPositionYSql = "CREATE INDEX idx_positiony ON mapobjects (positiony);"; +dbContext.Database.ExecuteSqlRaw(createIndexPositionXSql); +dbContext.Database.ExecuteSqlRaw(createIndexPositionYSql); + +// Creating or replacing the function +var createFunctionSql = @" +CREATE OR REPLACE FUNCTION refresh_mapobjects() +RETURNS TRIGGER AS $$ +BEGIN + REFRESH MATERIALIZED VIEW CONCURRENTLY mapobjects; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; +"; +dbContext.Database.ExecuteSqlRaw(createFunctionSql); + +// Creating the trigger +var dropTriggerSql = @" +DROP TRIGGER IF EXISTS refresh_mapobjects_trigger ON ""UtxoCborByAddresses""; +"; +dbContext.Database.ExecuteSqlRaw(dropTriggerSql); + +var createTriggerSql = @" +CREATE TRIGGER refresh_mapobjects_trigger +AFTER INSERT OR UPDATE OR DELETE ON ""UtxoCborByAddresses"" +FOR EACH STATEMENT EXECUTE FUNCTION refresh_mapobjects(); +"; +dbContext.Database.ExecuteSqlRaw(createTriggerSql); + +app.Run(); \ No newline at end of file diff --git a/indexer/Properties/launchSettings.json b/indexer/Properties/launchSettings.json new file mode 100644 index 0000000..8c008cf --- /dev/null +++ b/indexer/Properties/launchSettings.json @@ -0,0 +1,41 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:21808", + "sslPort": 44325 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5252", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7065;http://localhost:5252", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/indexer/Reducers/UtxoCborByAddressReducer.cs b/indexer/Reducers/UtxoCborByAddressReducer.cs new file mode 100644 index 0000000..df21265 --- /dev/null +++ b/indexer/Reducers/UtxoCborByAddressReducer.cs @@ -0,0 +1,57 @@ +using System.Text.Json; +using Asteria.Indexer.Data; +using Cardano.Sync.Reducers; +using Microsoft.EntityFrameworkCore; +using PallasDotnet.Models; + +namespace Asteria.Indexer.Reduers; + +public class UtxoCborByAddressReducer( + IDbContextFactory dbContextFactory, + IConfiguration configuration +) : IReducer +{ + + public async Task RollForwardAsync(NextResponse response) + { + using var dbContext = dbContextFactory.CreateDbContext(); + var trackedAddressed = JsonSerializer.Deserialize(configuration.GetValue("UtxoAddresses") ?? "[]"); + // Process Inputs + response.Block.TransactionBodies.ToList().ForEach(tx => tx.Inputs.ToList().ForEach(input => + { + dbContext.UtxoCborByAddresses.RemoveRange( + dbContext.UtxoCborByAddresses.Where(x => x.TxHash == input.Id.ToHex() && x.OutputIndex == input.Index).AsNoTracking() + ); + })); + + // Process Outputs + response.Block.TransactionBodies.ToList().ForEach(tx => tx.Outputs.ToList().ForEach(output => + { + var address = output.Address.ToBech32(); + + // Skip if address is not tracked + if (!(trackedAddressed?.Contains(address) ?? false)) return; + + dbContext.UtxoCborByAddresses.Add(new ( + tx.Id.ToHex(), + output.Index, + response.Block.Slot, + address, + tx.Era, + output.Raw + )); + })); + + await dbContext.SaveChangesAsync(); + } + + public async Task RollBackwardAsync(NextResponse response) + { + using var dbContext = dbContextFactory.CreateDbContext(); + dbContext.UtxoCborByAddresses.RemoveRange( + dbContext.UtxoCborByAddresses.Where(x => x.Slot > response.Block.Slot).AsNoTracking() + ); + + await dbContext.SaveChangesAsync(); + } +} \ No newline at end of file diff --git a/indexer/Worker.cs b/indexer/Worker.cs new file mode 100644 index 0000000..e69de29 diff --git a/indexer/indexer.http b/indexer/indexer.http new file mode 100644 index 0000000..ad745b6 --- /dev/null +++ b/indexer/indexer.http @@ -0,0 +1,6 @@ +@indexer_HostAddress = http://localhost:5252 + +GET {{indexer_HostAddress}}/weatherforecast/ +Accept: application/json + +###