From b973d07a815bd085c62bf05d2b504db25a16c41e Mon Sep 17 00:00:00 2001 From: Enzo Cioppettini Date: Thu, 21 Dec 2023 17:54:03 -0300 Subject: [PATCH] add index for utxos by native asset --- docs/bin/openapi.json | 141 ++++++++++++ indexer/entity/src/asset_utxos.rs | 59 +++++ indexer/entity/src/lib.rs | 1 + indexer/entity/src/prelude.rs | 4 + indexer/execution_plans/default.toml | 2 + indexer/migration/src/lib.rs | 2 + .../src/m20231220_000018_asset_utxo_table.rs | 80 +++++++ indexer/tasks/src/multiera/mod.rs | 1 + .../tasks/src/multiera/multiera_asset_utxo.rs | 207 ++++++++++++++++++ .../app/controllers/AssetUtxosController.ts | 78 +++++++ .../app/models/asset/assetUtxos.queries.ts | 58 +++++ .../server/app/models/asset/assetUtxos.sql | 25 +++ webserver/server/app/services/AssetUtxos.ts | 21 ++ webserver/shared/constants.ts | 6 +- webserver/shared/errors.ts | 7 + webserver/shared/models/AssetUtxos.ts | 28 +++ webserver/shared/routes.ts | 7 + 17 files changed, 726 insertions(+), 1 deletion(-) create mode 100644 indexer/entity/src/asset_utxos.rs create mode 100644 indexer/migration/src/m20231220_000018_asset_utxo_table.rs create mode 100644 indexer/tasks/src/multiera/multiera_asset_utxo.rs create mode 100644 webserver/server/app/controllers/AssetUtxosController.ts create mode 100644 webserver/server/app/models/asset/assetUtxos.queries.ts create mode 100644 webserver/server/app/models/asset/assetUtxos.sql create mode 100644 webserver/server/app/services/AssetUtxos.ts create mode 100644 webserver/shared/models/AssetUtxos.ts diff --git a/docs/bin/openapi.json b/docs/bin/openapi.json index c1fb57ff..e6df8d11 100644 --- a/docs/bin/openapi.json +++ b/docs/bin/openapi.json @@ -146,6 +146,88 @@ ], "type": "object" }, + "AssetUtxosResponse": { + "items": { + "properties": { + "txId": { + "type": "string" + }, + "slot": { + "type": "number", + "format": "double" + }, + "paymentCred": { + "type": "string" + }, + "utxo": { + "properties": { + "index": { + "type": "number", + "format": "double" + }, + "tx": { + "type": "string" + } + }, + "required": [ + "index", + "tx" + ], + "type": "object" + }, + "amount": { + "type": "number", + "format": "double", + "description": "If the utxo is created, this has the amount. It's undefined if the utxo\nis spent.", + "example": 1031423725351 + } + }, + "required": [ + "txId", + "slot", + "paymentCred", + "utxo" + ], + "type": "object" + }, + "type": "array" + }, + "Cip14Fingerprint": { + "type": "string", + "example": "asset1c43p68zwjezc7f6w4w9qkhkwv9ppwz0f7c3amw" + }, + "AssetUtxosRequest": { + "properties": { + "assets": { + "items": { + "$ref": "#/components/schemas/Cip14Fingerprint" + }, + "type": "array" + }, + "range": { + "properties": { + "maxSlot": { + "type": "number", + "format": "double" + }, + "minSlot": { + "type": "number", + "format": "double" + } + }, + "required": [ + "maxSlot", + "minSlot" + ], + "type": "object" + } + }, + "required": [ + "assets", + "range" + ], + "type": "object" + }, "BlockSubset": { "properties": { "slot": { @@ -1146,6 +1228,65 @@ } } }, + "/asset/utxos": { + "post": { + "operationId": "AssetUtxos", + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetUtxosResponse" + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorShape" + } + } + } + }, + "409": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorShape" + } + } + } + }, + "422": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorShape" + } + } + } + } + }, + "security": [], + "parameters": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AssetUtxosRequest" + } + } + } + } + } + }, "/block/latest": { "post": { "operationId": "BlockLatest", diff --git a/indexer/entity/src/asset_utxos.rs b/indexer/entity/src/asset_utxos.rs new file mode 100644 index 00000000..cc8bb8c5 --- /dev/null +++ b/indexer/entity/src/asset_utxos.rs @@ -0,0 +1,59 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel, Deserialize, Serialize)] +#[sea_orm(table_name = "AssetUtxo")] +pub struct Model { + #[sea_orm(primary_key, column_type = "BigInteger")] + pub id: i64, + #[sea_orm(column_type = "BigInteger")] + pub asset_id: i64, + #[sea_orm(column_type = "BigInteger")] + pub utxo_id: i64, + pub amount: Option, + pub tx_id: i64, +} + +#[derive(Copy, Clone, Debug, DeriveRelation, EnumIter)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::transaction_output::Entity", + from = "Column::UtxoId", + to = "super::transaction_output::Column::Id" + )] + TransactionOutput, + + #[sea_orm( + belongs_to = "super::native_asset::Entity", + from = "Column::AssetId", + to = "super::native_asset::Column::Id" + )] + Asset, + + #[sea_orm( + belongs_to = "super::transaction::Entity", + from = "Column::TxId", + to = "super::transaction::Column::Id" + )] + Transaction, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::TransactionOutput.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Asset.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Transaction.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/indexer/entity/src/lib.rs b/indexer/entity/src/lib.rs index 1465d3a9..f22250d5 100644 --- a/indexer/entity/src/lib.rs +++ b/indexer/entity/src/lib.rs @@ -10,6 +10,7 @@ pub mod transaction_reference_input; pub mod tx_credential; pub use sea_orm; pub mod asset_mint; +pub mod asset_utxos; pub mod cip25_entry; pub mod dex_swap; pub mod native_asset; diff --git a/indexer/entity/src/prelude.rs b/indexer/entity/src/prelude.rs index b38d49b6..efe0ccb2 100644 --- a/indexer/entity/src/prelude.rs +++ b/indexer/entity/src/prelude.rs @@ -11,6 +11,10 @@ pub use super::asset_mint::{ ActiveModel as AssetMintActiveModel, Column as AssetMintColumn, Entity as AssetMint, Model as AssetMintModel, PrimaryKey as AssetMintPrimaryKey, Relation as AssetMintRelation, }; +pub use super::asset_utxos::{ + ActiveModel as AssetUtxoActiveModel, Column as AssetUtxoCredentialColumn, Entity as AssetUtxo, + Model as AssetUtxoModel, PrimaryKey as AssetUtxoPrimaryKey, Relation as AssetUtxoRelation, +}; pub use super::block::{ ActiveModel as BlockActiveModel, Column as BlockColumn, Entity as Block, Model as BlockModel, PrimaryKey as BlockPrimaryKey, Relation as BlockRelation, diff --git a/indexer/execution_plans/default.toml b/indexer/execution_plans/default.toml index c9cbdbf0..0a08c3ca 100644 --- a/indexer/execution_plans/default.toml +++ b/indexer/execution_plans/default.toml @@ -65,3 +65,5 @@ readonly=false [MultieraCip25EntryTask] [MultieraAddressDelegationTask] + +[MultieraAssetUtxos] diff --git a/indexer/migration/src/lib.rs b/indexer/migration/src/lib.rs index bb76d0cc..11f27ea8 100644 --- a/indexer/migration/src/lib.rs +++ b/indexer/migration/src/lib.rs @@ -19,6 +19,7 @@ mod m20221031_000014_create_dex_table; mod m20230223_000015_modify_block_table; mod m20230927_000016_create_stake_delegation_table; mod m20231025_000017_projected_nft; +mod m20231220_000018_asset_utxo_table; pub struct Migrator; @@ -45,6 +46,7 @@ impl MigratorTrait for Migrator { Box::new(m20230223_000015_modify_block_table::Migration), Box::new(m20230927_000016_create_stake_delegation_table::Migration), Box::new(m20231025_000017_projected_nft::Migration), + Box::new(m20231220_000018_asset_utxo_table::Migration), ] } } diff --git a/indexer/migration/src/m20231220_000018_asset_utxo_table.rs b/indexer/migration/src/m20231220_000018_asset_utxo_table.rs new file mode 100644 index 00000000..65f242f1 --- /dev/null +++ b/indexer/migration/src/m20231220_000018_asset_utxo_table.rs @@ -0,0 +1,80 @@ +use entity::{asset_utxos::*, prelude}; +use sea_schema::migration::prelude::*; + +pub struct Migration; + +impl MigrationName for Migration { + fn name(&self) -> &str { + "m20231220_000018_asset_utxos.rs" + } +} + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Entity) + .if_not_exists() + .col( + ColumnDef::new(Column::Id) + .big_integer() + .not_null() + .auto_increment(), + ) + .col(ColumnDef::new(Column::AssetId).big_integer().not_null()) + .col(ColumnDef::new(Column::UtxoId).big_integer().not_null()) + .col(ColumnDef::new(Column::TxId).big_integer().not_null()) + .col(ColumnDef::new(Column::Amount).big_integer()) + .foreign_key( + ForeignKey::create() + .name("fk-asset_utxo-asset_id") + .from(Entity, Column::AssetId) + .to(prelude::NativeAsset, prelude::NativeAssetColumn::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .foreign_key( + ForeignKey::create() + .name("fk-asset_utxo-tx_id") + .from(Entity, Column::TxId) + .to(prelude::Transaction, prelude::TransactionColumn::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .foreign_key( + ForeignKey::create() + .name("fk-asset_utxo-utxo_id") + .from(Entity, Column::UtxoId) + .to( + prelude::TransactionOutput, + prelude::TransactionOutputColumn::Id, + ) + .on_delete(ForeignKeyAction::Cascade), + ) + .primary_key( + Index::create() + .table(Entity) + .name("asset_utxo-pk") + .col(Column::Id), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .table(Entity) + .name("index-asset_utxo-transaction") + .col(Column::TxId) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Entity).to_owned()) + .await + } +} diff --git a/indexer/tasks/src/multiera/mod.rs b/indexer/tasks/src/multiera/mod.rs index 06ab35f9..9ad6a302 100644 --- a/indexer/tasks/src/multiera/mod.rs +++ b/indexer/tasks/src/multiera/mod.rs @@ -3,6 +3,7 @@ pub mod multiera_address; pub mod multiera_address_credential_relations; pub mod multiera_address_delegation; pub mod multiera_asset_mint; +pub mod multiera_asset_utxo; pub mod multiera_block; pub mod multiera_cip25entry; pub mod multiera_datum; diff --git a/indexer/tasks/src/multiera/multiera_asset_utxo.rs b/indexer/tasks/src/multiera/multiera_asset_utxo.rs new file mode 100644 index 00000000..c3657132 --- /dev/null +++ b/indexer/tasks/src/multiera/multiera_asset_utxo.rs @@ -0,0 +1,207 @@ +use super::multiera_stake_credentials::MultieraStakeCredentialTask; +use crate::config::AddressConfig::PayloadAndReadonlyConfig; +use crate::config::EmptyConfig::EmptyConfig; +use crate::config::ReadonlyConfig::ReadonlyConfig; +use crate::dsl::task_macro::*; +use crate::multiera::dex::common::filter_outputs_and_datums_by_address; +use crate::multiera::multiera_txs::MultieraTransactionTask; +use crate::multiera::multiera_used_inputs::MultieraUsedInputTask; +use crate::multiera::multiera_used_outputs::MultieraOutputTask; +use crate::types::AddressCredentialRelationValue; +use cardano_multiplatform_lib::error::DeserializeError; +use cml_core::serialization::{FromBytes, ToBytes}; +use cml_crypto::RawBytesEncoding; +use entity::sea_orm::Condition; +use entity::transaction_output::Model; +use entity::{ + prelude::*, + sea_orm::{prelude::*, DatabaseTransaction, Set}, +}; +use pallas::ledger::primitives::babbage::DatumOption; +use pallas::ledger::primitives::Fragment; +use pallas::ledger::traverse::{Asset, MultiEraInput, MultiEraOutput}; +use projected_nft_sdk::{Owner, State, Status}; +use sea_orm::{FromQueryResult, JoinType, QuerySelect, QueryTrait}; +use std::collections::{BTreeSet, HashMap}; + +carp_task! { + name MultieraAssetUtxos; + configuration EmptyConfig; + doc "Parses utxo movements for native assets"; + era multiera; + dependencies [MultieraUsedInputTask, MultieraOutputTask]; + read [multiera_txs, multiera_outputs, multiera_used_inputs_to_outputs_map]; + write []; + should_add_task |block, _properties| { + !block.1.is_empty() + }; + execute |previous_data, task| handle( + task.db_tx, + task.block, + &previous_data.multiera_txs, + &previous_data.multiera_outputs, + &previous_data.multiera_used_inputs_to_outputs_map, + ); + merge_result |previous_data, _result| { + }; +} + +async fn handle( + db_tx: &DatabaseTransaction, + block: BlockInfo<'_, MultiEraBlock<'_>, BlockGlobalInfo>, + multiera_txs: &[TransactionModel], + multiera_outputs: &[TransactionOutputModel], + multiera_used_inputs_to_outputs_map: &BTreeMap, BTreeMap>, +) -> Result<(), DbErr> { + let mut queued_inserts = vec![]; + + // this stores the result before searching for the asset ids in the table + struct PartialEntry { + utxo_id: i64, + amount: Option, + tx_id: i64, + // policy_id + asset_name + asset: (Vec, Vec), + } + + let mut condition = Condition::any(); + + let outputs_map: HashMap<_, _> = multiera_outputs + .iter() + .map(|output| ((output.tx_id, output.output_index), output)) + .collect(); + + for (tx_body, cardano_transaction) in block.1.txs().iter().zip(multiera_txs) { + for input in tx_body.inputs().iter().chain(tx_body.collateral().iter()) { + let utxo = multiera_used_inputs_to_outputs_map + .get(input.hash().as_ref()) + .and_then(|by_index| by_index.get(&(input.index() as i64))); + + let utxo = if let Some(utxo) = utxo { + utxo + } else { + // this can happen if the tx was not valid, in which case the + // input is not spent. + continue; + }; + + let output = MultiEraOutput::decode(utxo.era, &utxo.model.payload).unwrap(); + + for asset in output.non_ada_assets() { + let (policy_id, asset_name, value) = match asset { + Asset::Ada(_) => continue, + Asset::NativeAsset(policy_id, asset_name, value) => { + (policy_id, asset_name, value) + } + }; + + if value == 0 { + continue; + } + + condition = condition.add( + Condition::all() + .add(entity::native_asset::Column::PolicyId.eq(policy_id.as_ref())) + .add(entity::native_asset::Column::AssetName.eq(asset_name.clone())), + ); + + queued_inserts.push(PartialEntry { + utxo_id: utxo.model.id, + amount: None, + tx_id: cardano_transaction.id, + asset: (policy_id.as_ref().to_vec(), asset_name), + }); + } + } + + for (output_index, output) in tx_body + .outputs() + .iter() + .chain(tx_body.collateral_return().iter()) + .enumerate() + { + let address = output + .address() + .map_err(|err| DbErr::Custom(format!("invalid pallas address: {}", err)))? + .to_vec(); + + let address = cardano_multiplatform_lib::address::Address::from_bytes(address) + .map_err(|err| DbErr::Custom(format!("cml can't parse address: {}", err)))?; + + if address.payment_cred().is_none() { + continue; + }; + + let output_model = match outputs_map.get(&(cardano_transaction.id, output_index as i32)) + { + None => { + continue; + } + Some(output) => output, + }; + + for asset in output.non_ada_assets() { + let (policy_id, asset_name, value) = match asset { + Asset::Ada(_) => continue, + Asset::NativeAsset(policy_id, asset_name, value) => { + (policy_id, asset_name, value) + } + }; + + if value == 0 { + continue; + } + + condition = condition.add( + Condition::all() + .add(entity::native_asset::Column::PolicyId.eq(policy_id.as_ref())) + .add(entity::native_asset::Column::AssetName.eq(asset_name.clone())), + ); + + queued_inserts.push(PartialEntry { + utxo_id: output_model.id, + amount: Some(value as i64), + tx_id: cardano_transaction.id, + asset: (policy_id.as_ref().to_vec(), asset_name), + }); + } + } + } + + if !queued_inserts.is_empty() { + let asset_map = entity::native_asset::Entity::find() + .filter(condition) + .all(db_tx) + .await? + .into_iter() + .map(|asset| ((asset.policy_id, asset.asset_name), asset.id)) + .collect::>(); + + AssetUtxo::insert_many( + queued_inserts + .into_iter() + .map(|partial_entry| { + let asset_id = asset_map.get(&partial_entry.asset).ok_or_else(|| { + DbErr::Custom(format!( + "Asset not found: {}-{}", + hex::encode(&partial_entry.asset.0), + String::from_utf8_lossy(&partial_entry.asset.1) + )) + })?; + + Ok(entity::asset_utxos::ActiveModel { + asset_id: Set(*asset_id), + utxo_id: Set(partial_entry.utxo_id), + amount: Set(partial_entry.amount), + tx_id: Set(partial_entry.tx_id), + ..Default::default() + }) + }) + .collect::, DbErr>>()?, + ) + .exec(db_tx) + .await?; + } + + Ok(()) +} diff --git a/webserver/server/app/controllers/AssetUtxosController.ts b/webserver/server/app/controllers/AssetUtxosController.ts new file mode 100644 index 00000000..5ef50ac6 --- /dev/null +++ b/webserver/server/app/controllers/AssetUtxosController.ts @@ -0,0 +1,78 @@ +import { Body, Controller, TsoaResponse, Res, Post, Route, SuccessResponse } from 'tsoa'; +import { StatusCodes } from 'http-status-codes'; +import tx from 'pg-tx'; +import pool from '../services/PgPoolSingleton'; +import { genErrorMessage, type ErrorShape, Errors } from '../../../shared/errors'; +import type { EndpointTypes } from '../../../shared/routes'; +import { Routes } from '../../../shared/routes'; +import { getAssetUtxos } from '../services/AssetUtxos'; +import type { AssetUtxosResponse } from '../../../shared/models/AssetUtxos'; +import type { IAssetUtxosResult } from '../models/asset/assetUtxos.queries'; +import { bech32 } from 'bech32'; +import { ASSET_UTXOS_LIMIT } from '../../../shared/constants'; +import { Address } from '@dcspark/cardano-multiplatform-lib-nodejs'; + +const route = Routes.assetUtxos; + +@Route('asset/utxos') +export class AssetUtxosController extends Controller { + @SuccessResponse(`${StatusCodes.OK}`) + @Post() + public async assetUtxos( + @Body() + requestBody: EndpointTypes[typeof route]['input'], + @Res() + errorResponse: TsoaResponse< + StatusCodes.BAD_REQUEST | StatusCodes.CONFLICT | StatusCodes.UNPROCESSABLE_ENTITY, + ErrorShape + > + ): Promise { + if (requestBody.assets.length > ASSET_UTXOS_LIMIT.ASSETS) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return errorResponse( + StatusCodes.BAD_REQUEST, + genErrorMessage(Errors.AssetLimitExceeded, { + limit: ASSET_UTXOS_LIMIT.ASSETS, + found: requestBody.assets.length, + }) + ); + } + + const response = await tx(pool, async dbTx => { + const data = await getAssetUtxos({ + range: requestBody.range, + assets: requestBody.assets.map(asset => { + const decoded = bech32.decode(asset); + const payload = bech32.fromWords(decoded.words); + + return Buffer.from(payload); + }), + dbTx, + }); + + return data.map((data: IAssetUtxosResult): AssetUtxosResponse[0] => { + const address = Address.from_bytes(Uint8Array.from(data.address_raw)); + + const paymentCred = address.payment_cred(); + const addressBytes = paymentCred?.to_bytes(); + + address.free(); + paymentCred?.free(); + + return { + txId: data.tx_hash as string, + utxo: { + index: data.output_index, + tx: data.output_tx_hash as string, + }, + paymentCred: Buffer.from(addressBytes as Uint8Array).toString('hex'), + amount: data.amount ? Number(data.amount) : undefined, + slot: data.slot, + cip14Fingerprint: bech32.encode('asset', bech32.toWords(data.cip14_fingerprint)), + }; + }); + }); + + return response; + } +} diff --git a/webserver/server/app/models/asset/assetUtxos.queries.ts b/webserver/server/app/models/asset/assetUtxos.queries.ts new file mode 100644 index 00000000..e3d41ec0 --- /dev/null +++ b/webserver/server/app/models/asset/assetUtxos.queries.ts @@ -0,0 +1,58 @@ +/** Types generated for queries found in "app/models/asset/assetUtxos.sql" */ +import { PreparedQuery } from '@pgtyped/query'; + +/** 'AssetUtxos' parameters type */ +export interface IAssetUtxosParams { + fingerprints: readonly (Buffer)[]; + max_slot: number; + min_slot: number; +} + +/** 'AssetUtxos' return type */ +export interface IAssetUtxosResult { + address_raw: Buffer; + amount: string | null; + cip14_fingerprint: Buffer; + output_index: number; + output_tx_hash: string | null; + slot: number; + tx_hash: string | null; +} + +/** 'AssetUtxos' query type */ +export interface IAssetUtxosQuery { + params: IAssetUtxosParams; + result: IAssetUtxosResult; +} + +const assetUtxosIR: any = {"usedParamSet":{"fingerprints":true,"min_slot":true,"max_slot":true},"params":[{"name":"fingerprints","required":true,"transform":{"type":"array_spread"},"locs":[{"a":677,"b":690}]},{"name":"min_slot","required":true,"transform":{"type":"scalar"},"locs":[{"a":712,"b":721}]},{"name":"max_slot","required":true,"transform":{"type":"scalar"},"locs":[{"a":744,"b":753}]}],"statement":"SELECT ENCODE(TXO.HASH,\n 'hex') OUTPUT_TX_HASH,\n \"TransactionOutput\".OUTPUT_INDEX,\n\t\"NativeAsset\".CIP14_FINGERPRINT,\n\t\"AssetUtxo\".AMOUNT,\n\t\"Block\".SLOT,\n\tENCODE(\"Transaction\".HASH,\n 'hex') TX_HASH,\n\t\"Address\".PAYLOAD ADDRESS_RAW\nFROM \"AssetUtxo\"\nJOIN \"Transaction\" ON \"AssetUtxo\".TX_ID = \"Transaction\".ID\nJOIN \"TransactionOutput\" ON \"AssetUtxo\".UTXO_ID = \"TransactionOutput\".ID\nJOIN \"Transaction\" TXO ON \"TransactionOutput\".TX_ID = TXO.ID\nJOIN \"Address\" ON \"Address\".id = \"TransactionOutput\".address_id\nJOIN \"NativeAsset\" ON \"AssetUtxo\".ASSET_ID = \"NativeAsset\".ID\nJOIN \"Block\" ON \"Transaction\".BLOCK_ID = \"Block\".ID\nWHERE \n\t\"NativeAsset\".CIP14_FINGERPRINT IN :fingerprints! AND\n\t\"Block\".SLOT > :min_slot! AND\n\t\"Block\".SLOT <= :max_slot!\nORDER BY \"Transaction\".ID ASC"}; + +/** + * Query generated from SQL: + * ``` + * SELECT ENCODE(TXO.HASH, + * 'hex') OUTPUT_TX_HASH, + * "TransactionOutput".OUTPUT_INDEX, + * "NativeAsset".CIP14_FINGERPRINT, + * "AssetUtxo".AMOUNT, + * "Block".SLOT, + * ENCODE("Transaction".HASH, + * 'hex') TX_HASH, + * "Address".PAYLOAD ADDRESS_RAW + * FROM "AssetUtxo" + * JOIN "Transaction" ON "AssetUtxo".TX_ID = "Transaction".ID + * JOIN "TransactionOutput" ON "AssetUtxo".UTXO_ID = "TransactionOutput".ID + * JOIN "Transaction" TXO ON "TransactionOutput".TX_ID = TXO.ID + * JOIN "Address" ON "Address".id = "TransactionOutput".address_id + * JOIN "NativeAsset" ON "AssetUtxo".ASSET_ID = "NativeAsset".ID + * JOIN "Block" ON "Transaction".BLOCK_ID = "Block".ID + * WHERE + * "NativeAsset".CIP14_FINGERPRINT IN :fingerprints! AND + * "Block".SLOT > :min_slot! AND + * "Block".SLOT <= :max_slot! + * ORDER BY "Transaction".ID ASC + * ``` + */ +export const assetUtxos = new PreparedQuery(assetUtxosIR); + + diff --git a/webserver/server/app/models/asset/assetUtxos.sql b/webserver/server/app/models/asset/assetUtxos.sql new file mode 100644 index 00000000..c2ea11f4 --- /dev/null +++ b/webserver/server/app/models/asset/assetUtxos.sql @@ -0,0 +1,25 @@ +/* +@name AssetUtxos +@param fingerprints -> (...) +*/ +SELECT ENCODE(TXO.HASH, + 'hex') OUTPUT_TX_HASH, + "TransactionOutput".OUTPUT_INDEX, + "NativeAsset".CIP14_FINGERPRINT, + "AssetUtxo".AMOUNT, + "Block".SLOT, + ENCODE("Transaction".HASH, + 'hex') TX_HASH, + "Address".PAYLOAD ADDRESS_RAW +FROM "AssetUtxo" +JOIN "Transaction" ON "AssetUtxo".TX_ID = "Transaction".ID +JOIN "TransactionOutput" ON "AssetUtxo".UTXO_ID = "TransactionOutput".ID +JOIN "Transaction" TXO ON "TransactionOutput".TX_ID = TXO.ID +JOIN "Address" ON "Address".id = "TransactionOutput".address_id +JOIN "NativeAsset" ON "AssetUtxo".ASSET_ID = "NativeAsset".ID +JOIN "Block" ON "Transaction".BLOCK_ID = "Block".ID +WHERE + "NativeAsset".CIP14_FINGERPRINT IN :fingerprints! AND + "Block".SLOT > :min_slot! AND + "Block".SLOT <= :max_slot! +ORDER BY "Transaction".ID ASC; diff --git a/webserver/server/app/services/AssetUtxos.ts b/webserver/server/app/services/AssetUtxos.ts new file mode 100644 index 00000000..0a8561be --- /dev/null +++ b/webserver/server/app/services/AssetUtxos.ts @@ -0,0 +1,21 @@ +import type { PoolClient } from 'pg'; +import type { IAssetUtxosResult } from '../models/asset/assetUtxos.queries'; +import { assetUtxos } from '../models/asset/assetUtxos.queries'; + +export async function getAssetUtxos(request: { + range: { + minSlot: number; + maxSlot: number; + }; + assets: Buffer[]; + dbTx: PoolClient; +}): Promise { + return await assetUtxos.run( + { + max_slot: request.range.maxSlot, + min_slot: request.range.minSlot, + fingerprints: request.assets, + }, + request.dbTx + ); +} diff --git a/webserver/shared/constants.ts b/webserver/shared/constants.ts index f5c7c900..fad51cd6 100644 --- a/webserver/shared/constants.ts +++ b/webserver/shared/constants.ts @@ -29,5 +29,9 @@ export const DEX_PRICE_LIMIT = { export const POOL_DELEGATION_LIMIT = { POOLS: 50, - SLOT_RANGE: 200, + SLOT_RANGE: 10000, }; + +export const ASSET_UTXOS_LIMIT = { + ASSETS: 50, +}; \ No newline at end of file diff --git a/webserver/shared/errors.ts b/webserver/shared/errors.ts index d37c09c4..53b556bf 100644 --- a/webserver/shared/errors.ts +++ b/webserver/shared/errors.ts @@ -14,6 +14,7 @@ export enum ErrorCodes { AssetPairLimitExceeded = 10, PoolsLimitExceeded = 11, SlotRangeLimitExceeded = 12, + AssetsLimitExceeded = 12, } export type ErrorShape = { @@ -100,6 +101,12 @@ export const Errors = { detailsGen: (details: { limit: number; found: number }) => `Limit of ${details.limit}, found ${details.found}`, }, + AssetsLimitExceeded: { + code: ErrorCodes.AssetLimitExceeded, + prefix: "Exceeded request native assets limit.", + detailsGen: (details: { limit: number; found: number }) => + `Limit of ${details.limit}, found ${details.found}`, + }, } as const; export function genErrorMessage( diff --git a/webserver/shared/models/AssetUtxos.ts b/webserver/shared/models/AssetUtxos.ts new file mode 100644 index 00000000..728f3be8 --- /dev/null +++ b/webserver/shared/models/AssetUtxos.ts @@ -0,0 +1,28 @@ +/** + * @example "asset1c43p68zwjezc7f6w4w9qkhkwv9ppwz0f7c3amw" + */ +export type Cip14Fingerprint = string; + +export type AssetUtxosRequest = { + range: { minSlot: number; maxSlot: number }, + assets: Cip14Fingerprint[] +}; + +export type AssetUtxosResponse = { + + /** + * If the utxo is created, this has the amount. It's undefined if the utxo + * is spent. + * + * @example 1031423725351 + */ + amount: number | undefined, + utxo: { + tx: string, + index: number, + }, + cip14Fingerprint: string, + paymentCred: string, + slot: number + txId: string, +}[]; diff --git a/webserver/shared/routes.ts b/webserver/shared/routes.ts index b61b95db..904d2542 100644 --- a/webserver/shared/routes.ts +++ b/webserver/shared/routes.ts @@ -31,6 +31,7 @@ import type { ProjectedNftRangeRequest, ProjectedNftRangeResponse, } from "./models/ProjectedNftRange"; +import { AssetUtxosRequest, AssetUtxosResponse } from "./models/AssetUtxos"; export enum Routes { transactionHistory = "transaction/history", @@ -45,6 +46,7 @@ export enum Routes { delegationForAddress = "delegation/address", delegationForPool = "delegation/pool", projectedNftEventsRange = "projected-nft/range", + assetUtxos = "asset/utxos", } export type EndpointTypes = { @@ -108,4 +110,9 @@ export type EndpointTypes = { input: ProjectedNftRangeRequest; response: ProjectedNftRangeResponse; }; + [Routes.assetUtxos]: { + name: typeof Routes.assetUtxos; + input: AssetUtxosRequest; + response: AssetUtxosResponse; + }; };