diff --git a/api-server/CHANGELOG.md b/api-server/CHANGELOG.md index f61b6a3db..d10a21d2e 100644 --- a/api-server/CHANGELOG.md +++ b/api-server/CHANGELOG.md @@ -8,6 +8,7 @@ The format is loosely based on [Keep a Changelog](https://keepachangelog.com/en/ ### Added - New endpoint was added: `/v2/transaction/{id}/output/{idx}`. +- New endpoint was added: `/v2/token/{id}/transactions` will return all transactions related to a token ### Changed - `/v2/token/ticker/{ticker}` will now return all tokens whose ticker has the specified `{ticker}` diff --git a/api-server/api-server-common/src/storage/impls/in_memory/mod.rs b/api-server/api-server-common/src/storage/impls/in_memory/mod.rs index c94d32a33..51fbae6f5 100644 --- a/api-server/api-server-common/src/storage/impls/in_memory/mod.rs +++ b/api-server/api-server-common/src/storage/impls/in_memory/mod.rs @@ -15,12 +15,13 @@ pub mod transactional; -use crate::storage::storage_api::{ - block_aux_data::{BlockAuxData, BlockWithExtraData}, - AmountWithDecimals, ApiServerStorageError, BlockInfo, CoinOrTokenStatistic, Delegation, - FungibleTokenData, LockedUtxo, NftWithOwner, Order, PoolBlockStats, PoolDataWithExtraInfo, - TransactionInfo, TransactionWithBlockInfo, Utxo, UtxoLock, UtxoWithExtraInfo, +use std::{ + cmp::{Ordering, Reverse}, + collections::{BTreeMap, BTreeSet}, + ops::Bound::{Excluded, Unbounded}, + sync::Arc, }; + use common::{ address::Address, chain::{ @@ -31,16 +32,33 @@ use common::{ }, primitives::{id::WithId, Amount, BlockHeight, CoinOrTokenId, Id, Idable}, }; -use itertools::Itertools as _; -use std::{ - cmp::Reverse, - collections::{BTreeMap, BTreeSet}, - ops::Bound::{Excluded, Unbounded}, - sync::Arc, + +use crate::storage::storage_api::{ + block_aux_data::{BlockAuxData, BlockWithExtraData}, + AmountWithDecimals, ApiServerStorageError, BlockInfo, CoinOrTokenStatistic, Delegation, + FungibleTokenData, LockedUtxo, NftWithOwner, Order, PoolBlockStats, PoolDataWithExtraInfo, + TokenTransaction, TransactionInfo, TransactionWithBlockInfo, Utxo, UtxoLock, UtxoWithExtraInfo, }; +use itertools::Itertools as _; + use super::CURRENT_STORAGE_VERSION; +#[derive(Debug, Clone, PartialEq, Eq)] +struct TokenTransactionOrderedByTxId(TokenTransaction); + +impl PartialOrd for TokenTransactionOrderedByTxId { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for TokenTransactionOrderedByTxId { + fn cmp(&self, other: &Self) -> Ordering { + self.0.tx_id.cmp(&other.0.tx_id) + } +} + #[derive(Debug, Clone)] struct ApiServerInMemoryStorage { block_table: BTreeMap, BlockWithExtraData>, @@ -48,6 +66,8 @@ struct ApiServerInMemoryStorage { address_balance_table: BTreeMap>>, address_locked_balance_table: BTreeMap>, address_transactions_table: BTreeMap>>>, + token_transactions_table: + BTreeMap>>, delegation_table: BTreeMap>, main_chain_blocks_table: BTreeMap>, pool_data_table: BTreeMap>, @@ -75,6 +95,7 @@ impl ApiServerInMemoryStorage { address_balance_table: BTreeMap::new(), address_locked_balance_table: BTreeMap::new(), address_transactions_table: BTreeMap::new(), + token_transactions_table: BTreeMap::new(), delegation_table: BTreeMap::new(), main_chain_blocks_table: BTreeMap::new(), pool_data_table: BTreeMap::new(), @@ -173,6 +194,27 @@ impl ApiServerInMemoryStorage { })) } + fn get_token_transactions( + &self, + token_id: TokenId, + len: u32, + tx_global_index: u64, + ) -> Result, ApiServerStorageError> { + Ok(self + .token_transactions_table + .get(&token_id) + .map_or_else(Vec::new, |transactions| { + transactions + .iter() + .rev() + .flat_map(|(_, txs)| txs.iter().map(|tx| &tx.0)) + .flat_map(|tx| (tx.tx_global_index < tx_global_index).then_some(tx)) + .cloned() + .take(len as usize) + .collect() + })) + } + fn get_block(&self, block_id: Id) -> Result, ApiServerStorageError> { let block_result = self.block_table.get(&block_id); let block = match block_result { @@ -214,7 +256,7 @@ impl ApiServerInMemoryStorage { additional_info: additinal_data.clone(), }, block_aux: *block_aux, - global_tx_index: *tx_global_index, + tx_global_index: *tx_global_index, } }, ) @@ -240,7 +282,7 @@ impl ApiServerInMemoryStorage { TransactionWithBlockInfo { tx_info: tx_info.clone(), block_aux: *block_aux, - global_tx_index: *tx_global_index, + tx_global_index: *tx_global_index, } }) .collect()) @@ -864,6 +906,20 @@ impl ApiServerInMemoryStorage { Ok(()) } + fn del_token_transactions_above_height( + &mut self, + block_height: BlockHeight, + ) -> Result<(), ApiServerStorageError> { + // Inefficient, but acceptable for testing with InMemoryStorage + + self.token_transactions_table.retain(|_, v| { + v.retain(|k, _| k <= &block_height); + !v.is_empty() + }); + + Ok(()) + } + fn set_address_balance_at_height( &mut self, address: &Address, @@ -942,6 +998,26 @@ impl ApiServerInMemoryStorage { Ok(()) } + fn set_token_transaction_at_height( + &mut self, + token_id: TokenId, + tx_id: Id, + block_height: BlockHeight, + tx_global_index: u64, + ) -> Result<(), ApiServerStorageError> { + self.token_transactions_table + .entry(token_id) + .or_default() + .entry(block_height) + .or_default() + .replace(TokenTransactionOrderedByTxId(TokenTransaction { + tx_global_index, + tx_id, + })); + + Ok(()) + } + fn set_mainchain_block( &mut self, block_id: Id, @@ -1087,11 +1163,16 @@ impl ApiServerInMemoryStorage { &mut self, outpoint: UtxoOutPoint, utxo: Utxo, - address: &str, + addresses: &[&str], block_height: BlockHeight, ) -> Result<(), ApiServerStorageError> { self.utxo_table.entry(outpoint.clone()).or_default().insert(block_height, utxo); - self.address_utxos.entry(address.into()).or_default().insert(outpoint); + for address in addresses { + self.address_utxos + .entry((*address).into()) + .or_default() + .insert(outpoint.clone()); + } Ok(()) } @@ -1099,14 +1180,19 @@ impl ApiServerInMemoryStorage { &mut self, outpoint: UtxoOutPoint, utxo: LockedUtxo, - address: &str, + addresses: &[&str], block_height: BlockHeight, ) -> Result<(), ApiServerStorageError> { self.locked_utxo_table .entry(outpoint.clone()) .or_default() .insert(block_height, utxo); - self.address_locked_utxos.entry(address.into()).or_default().insert(outpoint); + for address in addresses { + self.address_locked_utxos + .entry((*address).into()) + .or_default() + .insert(outpoint.clone()); + } Ok(()) } diff --git a/api-server/api-server-common/src/storage/impls/in_memory/transactional/read.rs b/api-server/api-server-common/src/storage/impls/in_memory/transactional/read.rs index 8665eee6a..4032a5525 100644 --- a/api-server/api-server-common/src/storage/impls/in_memory/transactional/read.rs +++ b/api-server/api-server-common/src/storage/impls/in_memory/transactional/read.rs @@ -26,8 +26,8 @@ use common::{ use crate::storage::storage_api::{ block_aux_data::BlockAuxData, AmountWithDecimals, ApiServerStorageError, ApiServerStorageRead, BlockInfo, CoinOrTokenStatistic, Delegation, FungibleTokenData, NftWithOwner, Order, - PoolBlockStats, PoolDataWithExtraInfo, TransactionInfo, TransactionWithBlockInfo, Utxo, - UtxoWithExtraInfo, + PoolBlockStats, PoolDataWithExtraInfo, TokenTransaction, TransactionInfo, + TransactionWithBlockInfo, Utxo, UtxoWithExtraInfo, }; use super::ApiServerInMemoryStorageTransactionalRo; @@ -68,6 +68,15 @@ impl ApiServerStorageRead for ApiServerInMemoryStorageTransactionalRo<'_> { self.transaction.get_address_transactions(address) } + async fn get_token_transactions( + &self, + token_id: TokenId, + len: u32, + tx_global_index: u64, + ) -> Result, ApiServerStorageError> { + self.transaction.get_token_transactions(token_id, len, tx_global_index) + } + async fn get_block( &self, block_id: Id, diff --git a/api-server/api-server-common/src/storage/impls/in_memory/transactional/write.rs b/api-server/api-server-common/src/storage/impls/in_memory/transactional/write.rs index 94a48ffde..7f7fbff4e 100644 --- a/api-server/api-server-common/src/storage/impls/in_memory/transactional/write.rs +++ b/api-server/api-server-common/src/storage/impls/in_memory/transactional/write.rs @@ -19,8 +19,8 @@ use crate::storage::storage_api::{ block_aux_data::{BlockAuxData, BlockWithExtraData}, AmountWithDecimals, ApiServerStorageError, ApiServerStorageRead, ApiServerStorageWrite, BlockInfo, CoinOrTokenStatistic, Delegation, FungibleTokenData, LockedUtxo, NftWithOwner, - Order, PoolBlockStats, PoolDataWithExtraInfo, TransactionInfo, TransactionWithBlockInfo, Utxo, - UtxoWithExtraInfo, + Order, PoolBlockStats, PoolDataWithExtraInfo, TokenTransaction, TransactionInfo, + TransactionWithBlockInfo, Utxo, UtxoWithExtraInfo, }; use common::{ address::Address, @@ -64,6 +64,13 @@ impl ApiServerStorageWrite for ApiServerInMemoryStorageTransactionalRw<'_> { self.transaction.del_address_transactions_above_height(block_height) } + async fn del_token_transactions_above_height( + &mut self, + block_height: BlockHeight, + ) -> Result<(), ApiServerStorageError> { + self.transaction.del_token_transactions_above_height(block_height) + } + async fn set_address_balance_at_height( &mut self, address: &Address, @@ -104,6 +111,21 @@ impl ApiServerStorageWrite for ApiServerInMemoryStorageTransactionalRw<'_> { .set_address_transactions_at_height(address, transactions, block_height) } + async fn set_token_transaction_at_height( + &mut self, + token_id: TokenId, + tx_id: Id, + block_height: BlockHeight, + tx_global_index: u64, + ) -> Result<(), ApiServerStorageError> { + self.transaction.set_token_transaction_at_height( + token_id, + tx_id, + block_height, + tx_global_index, + ) + } + async fn set_mainchain_block( &mut self, block_id: Id, @@ -176,21 +198,21 @@ impl ApiServerStorageWrite for ApiServerInMemoryStorageTransactionalRw<'_> { &mut self, outpoint: UtxoOutPoint, utxo: Utxo, - address: &str, + addresses: &[&str], block_height: BlockHeight, ) -> Result<(), ApiServerStorageError> { - self.transaction.set_utxo_at_height(outpoint, utxo, address, block_height) + self.transaction.set_utxo_at_height(outpoint, utxo, addresses, block_height) } async fn set_locked_utxo_at_height( &mut self, outpoint: UtxoOutPoint, utxo: LockedUtxo, - address: &str, + addresses: &[&str], block_height: BlockHeight, ) -> Result<(), ApiServerStorageError> { self.transaction - .set_locked_utxo_at_height(outpoint, utxo, address, block_height) + .set_locked_utxo_at_height(outpoint, utxo, addresses, block_height) } async fn del_utxo_above_height( @@ -331,6 +353,15 @@ impl ApiServerStorageRead for ApiServerInMemoryStorageTransactionalRw<'_> { self.transaction.get_address_transactions(address) } + async fn get_token_transactions( + &self, + token_id: TokenId, + len: u32, + tx_global_index: u64, + ) -> Result, ApiServerStorageError> { + self.transaction.get_token_transactions(token_id, len, tx_global_index) + } + async fn get_latest_blocktimestamps( &self, ) -> Result, ApiServerStorageError> { diff --git a/api-server/api-server-common/src/storage/impls/mod.rs b/api-server/api-server-common/src/storage/impls/mod.rs index a91224287..dae7035ca 100644 --- a/api-server/api-server-common/src/storage/impls/mod.rs +++ b/api-server/api-server-common/src/storage/impls/mod.rs @@ -13,7 +13,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -pub const CURRENT_STORAGE_VERSION: u32 = 23; +pub const CURRENT_STORAGE_VERSION: u32 = 24; pub mod in_memory; pub mod postgres; diff --git a/api-server/api-server-common/src/storage/impls/postgres/queries.rs b/api-server/api-server-common/src/storage/impls/postgres/queries.rs index a10167ca6..8d7ba5d94 100644 --- a/api-server/api-server-common/src/storage/impls/postgres/queries.rs +++ b/api-server/api-server-common/src/storage/impls/postgres/queries.rs @@ -39,7 +39,7 @@ use crate::storage::{ block_aux_data::{BlockAuxData, BlockWithExtraData}, AmountWithDecimals, ApiServerStorageError, BlockInfo, CoinOrTokenStatistic, Delegation, FungibleTokenData, LockedUtxo, NftWithOwner, Order, PoolBlockStats, PoolDataWithExtraInfo, - TransactionInfo, TransactionWithBlockInfo, Utxo, UtxoWithExtraInfo, + TokenTransaction, TransactionInfo, TransactionWithBlockInfo, Utxo, UtxoWithExtraInfo, }, }; @@ -83,11 +83,13 @@ impl<'a, 'b> QueryFromConnection<'a, 'b> { .map_err(|_| ApiServerStorageError::TimestampTooHigh(block_timestamp)) } - fn tx_global_index_to_postgres_friendly(tx_global_index: u64) -> i64 { + fn tx_global_index_to_postgres_friendly( + tx_global_index: u64, + ) -> Result { // Postgres doesn't like u64, so we have to convert it to i64 tx_global_index .try_into() - .unwrap_or_else(|e| panic!("Invalid tx global index: {e}")) + .map_err(|_| ApiServerStorageError::TxGlobalIndexTooHigh(tx_global_index)) } pub async fn is_initialized(&mut self) -> Result { @@ -495,6 +497,50 @@ impl<'a, 'b> QueryFromConnection<'a, 'b> { Ok(transaction_ids) } + pub async fn get_token_transactions( + &self, + token_id: TokenId, + len: u32, + tx_global_index: u64, + ) -> Result, ApiServerStorageError> { + let tx_global_index = Self::tx_global_index_to_postgres_friendly(tx_global_index)?; + let len = len as i64; + let rows = self + .tx + .query( + r#" + SELECT tx_global_index, transaction_id + FROM ml.token_transactions + WHERE token_id = $1 AND tx_global_index < $2 + ORDER BY tx_global_index DESC + LIMIT $3; + "#, + &[&token_id.encode(), &tx_global_index, &len], + ) + .await + .map_err(|e| ApiServerStorageError::LowLevelStorageError(e.to_string()))?; + + let mut transactions = Vec::with_capacity(rows.len()); + + for row in &rows { + let tx_global_index: i64 = row.get(0); + let tx_id: Vec = row.get(1); + let tx_id = Id::::decode_all(&mut tx_id.as_slice()).map_err(|e| { + ApiServerStorageError::DeserializationError(format!( + "Transaction id deserialization failed: {}", + e + )) + })?; + + transactions.push(TokenTransaction { + tx_global_index: tx_global_index as u64, + tx_id, + }); + } + + Ok(transactions) + } + pub async fn del_address_transactions_above_height( &mut self, block_height: BlockHeight, @@ -512,6 +558,23 @@ impl<'a, 'b> QueryFromConnection<'a, 'b> { Ok(()) } + pub async fn del_token_transactions_above_height( + &mut self, + block_height: BlockHeight, + ) -> Result<(), ApiServerStorageError> { + let height = Self::block_height_to_postgres_friendly(block_height); + + self.tx + .execute( + "DELETE FROM ml.token_transactions WHERE block_height > $1;", + &[&height], + ) + .await + .map_err(|e| ApiServerStorageError::LowLevelStorageError(e.to_string()))?; + + Ok(()) + } + pub async fn set_address_transactions_at_height( &mut self, address: &str, @@ -538,6 +601,32 @@ impl<'a, 'b> QueryFromConnection<'a, 'b> { Ok(()) } + pub async fn set_token_transactions_at_height( + &mut self, + token_id: TokenId, + transaction_id: Id, + block_height: BlockHeight, + tx_global_index: u64, + ) -> Result<(), ApiServerStorageError> { + let height = Self::block_height_to_postgres_friendly(block_height); + let tx_global_index = Self::tx_global_index_to_postgres_friendly(tx_global_index)?; + + self.tx + .execute( + r#" + INSERT INTO ml.token_transactions (token_id, block_height, transaction_id, tx_global_index) + VALUES ($1, $2, $3, $4) + ON CONFLICT (token_id, transaction_id, block_height) + DO NOTHING; + "#, + &[&token_id.encode(), &height, &transaction_id.encode(), &tx_global_index], + ) + .await + .map_err(|e| ApiServerStorageError::LowLevelStorageError(e.to_string()))?; + + Ok(()) + } + pub async fn get_latest_blocktimestamps( &self, ) -> Result, ApiServerStorageError> { @@ -677,7 +766,7 @@ impl<'a, 'b> QueryFromConnection<'a, 'b> { self.just_execute( "CREATE TABLE ml.transactions ( transaction_id bytea PRIMARY KEY, - global_tx_index bigint NOT NULL, + tx_global_index bigint NOT NULL, owning_block_id bytea NOT NULL REFERENCES ml.blocks(block_id), transaction_data bytea NOT NULL );", // block_id can be null if the transaction is not in the main chain @@ -732,12 +821,25 @@ impl<'a, 'b> QueryFromConnection<'a, 'b> { ) .await?; + self.just_execute( + "CREATE TABLE ml.token_transactions ( + tx_global_index bigint PRIMARY KEY, + token_id bytea NOT NULL, + block_height bigint NOT NULL, + transaction_id bytea NOT NULL, + UNIQUE (token_id, transaction_id, block_height) + );", + ) + .await?; + + self.just_execute("CREATE INDEX token_transactions_global_tx_index ON ml.token_transactions (token_id, tx_global_index DESC);") + .await?; + self.just_execute( "CREATE TABLE ml.utxo ( outpoint bytea NOT NULL, block_height bigint, spent BOOLEAN NOT NULL, - address TEXT NOT NULL, utxo bytea NOT NULL, PRIMARY KEY (outpoint, block_height) );", @@ -752,7 +854,6 @@ impl<'a, 'b> QueryFromConnection<'a, 'b> { "CREATE TABLE ml.locked_utxo ( outpoint bytea NOT NULL, block_height bigint, - address TEXT NOT NULL, utxo bytea NOT NULL, lock_until_block bigint, lock_until_timestamp bigint, @@ -761,6 +862,25 @@ impl<'a, 'b> QueryFromConnection<'a, 'b> { ) .await?; + self.just_execute( + "CREATE TABLE ml.utxo_addresses ( + outpoint bytea NOT NULL, + block_height bigint NOT NULL, + address TEXT NOT NULL, + PRIMARY KEY (outpoint, address) + );", + ) + .await?; + + self.just_execute("CREATE INDEX utxo_addresses_index ON ml.utxo_addresses (address);") + .await?; + + // index for reorgs + self.just_execute( + "CREATE INDEX utxo_addresses_block_height_index ON ml.utxo_addresses (block_height DESC);", + ) + .await?; + self.just_execute( "CREATE TABLE ml.block_aux_data ( block_id bytea PRIMARY KEY REFERENCES ml.blocks(block_id), @@ -1803,12 +1923,12 @@ impl<'a, 'b> QueryFromConnection<'a, 'b> { SELECT t.transaction_data, b.aux_data, - t.global_tx_index + t.tx_global_index FROM ml.transactions t INNER JOIN ml.block_aux_data b ON t.owning_block_id = b.block_id - ORDER BY t.global_tx_index DESC + ORDER BY t.tx_global_index DESC OFFSET $1 LIMIT $2; "#, @@ -1821,7 +1941,7 @@ impl<'a, 'b> QueryFromConnection<'a, 'b> { .map(|data| { let transaction_data: Vec = data.get(0); let block_data: Vec = data.get(1); - let global_tx_index: i64 = data.get(2); + let tx_global_index: i64 = data.get(2); let block_aux = BlockAuxData::decode_all(&mut block_data.as_slice()).map_err(|e| { @@ -1841,7 +1961,7 @@ impl<'a, 'b> QueryFromConnection<'a, 'b> { Ok(TransactionWithBlockInfo { tx_info, block_aux, - global_tx_index: global_tx_index as u64, + tx_global_index: tx_global_index as u64, }) }) .collect() @@ -1850,10 +1970,10 @@ impl<'a, 'b> QueryFromConnection<'a, 'b> { pub async fn get_transactions_with_block_before_tx_global_index( &self, len: u32, - global_tx_index: u64, + tx_global_index: u64, ) -> Result, ApiServerStorageError> { let len = len as i64; - let tx_global_index = Self::tx_global_index_to_postgres_friendly(global_tx_index); + let tx_global_index = Self::tx_global_index_to_postgres_friendly(tx_global_index)?; let rows = self .tx .query( @@ -1861,13 +1981,13 @@ impl<'a, 'b> QueryFromConnection<'a, 'b> { SELECT t.transaction_data, b.aux_data, - t.global_tx_index + t.tx_global_index FROM ml.transactions t INNER JOIN ml.block_aux_data b ON t.owning_block_id = b.block_id - WHERE t.global_tx_index < $1 - ORDER BY t.global_tx_index DESC + WHERE t.tx_global_index < $1 + ORDER BY t.tx_global_index DESC LIMIT $2; "#, &[&tx_global_index, &len], @@ -1879,7 +1999,7 @@ impl<'a, 'b> QueryFromConnection<'a, 'b> { .map(|data| { let transaction_data: Vec = data.get(0); let block_data: Vec = data.get(1); - let global_tx_index: i64 = data.get(2); + let tx_global_index: i64 = data.get(2); let block_aux = BlockAuxData::decode_all(&mut block_data.as_slice()).map_err(|e| { @@ -1899,7 +2019,7 @@ impl<'a, 'b> QueryFromConnection<'a, 'b> { Ok(TransactionWithBlockInfo { tx_info, block_aux, - global_tx_index: global_tx_index as u64, + tx_global_index: tx_global_index as u64, }) }) .collect() @@ -1913,7 +2033,7 @@ impl<'a, 'b> QueryFromConnection<'a, 'b> { .query_one( r#" SELECT - MAX(t.global_tx_index) + MAX(t.tx_global_index) FROM ml.transactions t; "#, @@ -1930,7 +2050,7 @@ impl<'a, 'b> QueryFromConnection<'a, 'b> { pub async fn set_transaction( &mut self, transaction_id: Id, - global_tx_index: u64, + tx_global_index: u64, owning_block: Id, transaction: &TransactionInfo, ) -> Result<(), ApiServerStorageError> { @@ -1939,13 +2059,13 @@ impl<'a, 'b> QueryFromConnection<'a, 'b> { transaction_id, owning_block ); - let global_tx_index = Self::tx_global_index_to_postgres_friendly(global_tx_index); + let tx_global_index = Self::tx_global_index_to_postgres_friendly(tx_global_index)?; self.tx.execute( - "INSERT INTO ml.transactions (transaction_id, owning_block_id, transaction_data, global_tx_index) VALUES ($1, $2, $3, $4) + "INSERT INTO ml.transactions (transaction_id, owning_block_id, transaction_data, tx_global_index) VALUES ($1, $2, $3, $4) ON CONFLICT (transaction_id) DO UPDATE - SET owning_block_id = $2, transaction_data = $3, global_tx_index = $4;", - &[&transaction_id.encode(), &owning_block.encode(), &transaction.encode(), &global_tx_index] + SET owning_block_id = $2, transaction_data = $3, tx_global_index = $4;", + &[&transaction_id.encode(), &owning_block.encode(), &transaction.encode(), &tx_global_index] ).await .map_err(|e| ApiServerStorageError::LowLevelStorageError(e.to_string()))?; @@ -1993,13 +2113,16 @@ impl<'a, 'b> QueryFromConnection<'a, 'b> { let rows = self .tx .query( - r#"SELECT outpoint, utxo - FROM ( - SELECT outpoint, utxo, spent, ROW_NUMBER() OVER(PARTITION BY outpoint ORDER BY block_height DESC) as newest + r#"SELECT ua.outpoint, u.utxo + FROM ml.utxo_addresses ua + CROSS JOIN LATERAL ( + SELECT utxo, spent FROM ml.utxo - WHERE address = $1 - ) AS sub - WHERE newest = 1 AND spent = false;"#, + WHERE outpoint = ua.outpoint + ORDER BY block_height DESC + LIMIT 1 + ) u + WHERE ua.address = $1 AND u.spent = false;"#, &[&address], ) .await @@ -2035,17 +2158,21 @@ impl<'a, 'b> QueryFromConnection<'a, 'b> { let rows = self .tx .query( - r#"SELECT outpoint, utxo - FROM ( - SELECT outpoint, utxo, spent, ROW_NUMBER() OVER(PARTITION BY outpoint ORDER BY block_height DESC) as newest + r#"SELECT ua.outpoint, u.utxo + FROM ml.utxo_addresses ua + CROSS JOIN LATERAL ( + SELECT utxo, spent FROM ml.utxo - WHERE address = $1 - ) AS sub - WHERE newest = 1 AND spent = false + WHERE outpoint = ua.outpoint + ORDER BY block_height DESC + LIMIT 1 + ) u + WHERE ua.address = $1 AND u.spent = false UNION ALL - SELECT outpoint, utxo - FROM ml.locked_utxo AS locked - WHERE locked.address = $1 AND NOT EXISTS (SELECT 1 FROM ml.utxo WHERE outpoint = locked.outpoint) + SELECT l.outpoint, l.utxo + FROM ml.locked_utxo AS l + INNER JOIN ml.utxo_addresses ua ON l.outpoint = ua.outpoint + WHERE ua.address = $1 AND NOT EXISTS (SELECT 1 FROM ml.utxo WHERE outpoint = l.outpoint) ;"#, &[&address], ) @@ -2120,7 +2247,7 @@ impl<'a, 'b> QueryFromConnection<'a, 'b> { &mut self, outpoint: UtxoOutPoint, utxo: Utxo, - address: &str, + addresses: &[&str], block_height: BlockHeight, ) -> Result<(), ApiServerStorageError> { logging::log::debug!("Inserting utxo {:?} for outpoint {:?}", utxo, outpoint); @@ -2129,14 +2256,25 @@ impl<'a, 'b> QueryFromConnection<'a, 'b> { self.tx .execute( - "INSERT INTO ml.utxo (outpoint, utxo, spent, address, block_height) VALUES ($1, $2, $3, $4, $5) + "INSERT INTO ml.utxo (outpoint, utxo, spent, block_height) VALUES ($1, $2, $3, $4) ON CONFLICT (outpoint, block_height) DO UPDATE SET utxo = $2, spent = $3;", - &[&outpoint.encode(), &utxo.utxo_with_extra_info().encode(), &spent, &address, &height], + &[&outpoint.encode(), &utxo.utxo_with_extra_info().encode(), &spent, &height], ) .await .map_err(|e| ApiServerStorageError::LowLevelStorageError(e.to_string()))?; + for address in addresses { + self.tx + .execute( + "INSERT INTO ml.utxo_addresses (outpoint, block_height, address) VALUES ($1, $2, $3) + ON CONFLICT (outpoint, address) DO NOTHING;", + &[&outpoint.encode(), &height, &address], + ) + .await + .map_err(|e| ApiServerStorageError::LowLevelStorageError(e.to_string()))?; + } + Ok(()) } @@ -2144,7 +2282,7 @@ impl<'a, 'b> QueryFromConnection<'a, 'b> { &mut self, outpoint: UtxoOutPoint, utxo: LockedUtxo, - address: &str, + addresses: &[&str], block_height: BlockHeight, ) -> Result<(), ApiServerStorageError> { logging::log::debug!("Inserting utxo {:?} for outpoint {:?}", utxo, outpoint); @@ -2155,13 +2293,24 @@ impl<'a, 'b> QueryFromConnection<'a, 'b> { self.tx .execute( - "INSERT INTO ml.locked_utxo (outpoint, utxo, lock_until_timestamp, lock_until_block, address, block_height) - VALUES ($1, $2, $3, $4, $5, $6);", - &[&outpoint.encode(), &utxo.utxo_with_extra_info().encode(), &lock_time, &lock_height, &address, &height], + "INSERT INTO ml.locked_utxo (outpoint, utxo, lock_until_timestamp, lock_until_block, block_height) + VALUES ($1, $2, $3, $4, $5);", + &[&outpoint.encode(), &utxo.utxo_with_extra_info().encode(), &lock_time, &lock_height, &height], ) .await .map_err(|e| ApiServerStorageError::LowLevelStorageError(e.to_string()))?; + for address in addresses { + self.tx + .execute( + "INSERT INTO ml.utxo_addresses (outpoint, block_height, address) VALUES ($1, $2, $3) + ON CONFLICT (outpoint, address) DO NOTHING;", + &[&outpoint.encode(), &height, &address], + ) + .await + .map_err(|e| ApiServerStorageError::LowLevelStorageError(e.to_string()))?; + } + Ok(()) } @@ -2176,6 +2325,14 @@ impl<'a, 'b> QueryFromConnection<'a, 'b> { .await .map_err(|e| ApiServerStorageError::LowLevelStorageError(e.to_string()))?; + self.tx + .execute( + "DELETE FROM ml.utxo_addresses WHERE block_height > $1;", + &[&height], + ) + .await + .map_err(|e| ApiServerStorageError::LowLevelStorageError(e.to_string()))?; + Ok(()) } @@ -2193,6 +2350,14 @@ impl<'a, 'b> QueryFromConnection<'a, 'b> { .await .map_err(|e| ApiServerStorageError::LowLevelStorageError(e.to_string()))?; + self.tx + .execute( + "DELETE FROM ml.utxo_addresses WHERE block_height > $1;", + &[&height], + ) + .await + .map_err(|e| ApiServerStorageError::LowLevelStorageError(e.to_string()))?; + Ok(()) } diff --git a/api-server/api-server-common/src/storage/impls/postgres/transactional/read.rs b/api-server/api-server-common/src/storage/impls/postgres/transactional/read.rs index fb77adbf5..d01189963 100644 --- a/api-server/api-server-common/src/storage/impls/postgres/transactional/read.rs +++ b/api-server/api-server-common/src/storage/impls/postgres/transactional/read.rs @@ -26,8 +26,8 @@ use crate::storage::{ storage_api::{ block_aux_data::BlockAuxData, AmountWithDecimals, ApiServerStorageError, ApiServerStorageRead, BlockInfo, CoinOrTokenStatistic, Delegation, FungibleTokenData, - NftWithOwner, Order, PoolBlockStats, PoolDataWithExtraInfo, TransactionInfo, - TransactionWithBlockInfo, Utxo, UtxoWithExtraInfo, + NftWithOwner, Order, PoolBlockStats, PoolDataWithExtraInfo, TokenTransaction, + TransactionInfo, TransactionWithBlockInfo, Utxo, UtxoWithExtraInfo, }, }; use std::collections::BTreeMap; @@ -94,6 +94,18 @@ impl ApiServerStorageRead for ApiServerPostgresTransactionalRo<'_> { Ok(res) } + async fn get_token_transactions( + &self, + token_id: TokenId, + len: u32, + tx_global_index: u64, + ) -> Result, ApiServerStorageError> { + let conn = QueryFromConnection::new(self.connection.as_ref().expect(CONN_ERR)); + let res = conn.get_token_transactions(token_id, len, tx_global_index).await?; + + Ok(res) + } + async fn get_latest_blocktimestamps( &self, ) -> Result, ApiServerStorageError> { diff --git a/api-server/api-server-common/src/storage/impls/postgres/transactional/write.rs b/api-server/api-server-common/src/storage/impls/postgres/transactional/write.rs index 90ba81ecd..bfca788fc 100644 --- a/api-server/api-server-common/src/storage/impls/postgres/transactional/write.rs +++ b/api-server/api-server-common/src/storage/impls/postgres/transactional/write.rs @@ -31,8 +31,8 @@ use crate::storage::{ block_aux_data::{BlockAuxData, BlockWithExtraData}, AmountWithDecimals, ApiServerStorageError, ApiServerStorageRead, ApiServerStorageWrite, BlockInfo, CoinOrTokenStatistic, Delegation, FungibleTokenData, LockedUtxo, NftWithOwner, - Order, PoolBlockStats, PoolDataWithExtraInfo, TransactionInfo, TransactionWithBlockInfo, - Utxo, UtxoWithExtraInfo, + Order, PoolBlockStats, PoolDataWithExtraInfo, TokenTransaction, TransactionInfo, + TransactionWithBlockInfo, Utxo, UtxoWithExtraInfo, }, }; @@ -80,6 +80,16 @@ impl ApiServerStorageWrite for ApiServerPostgresTransactionalRw<'_> { Ok(()) } + async fn del_token_transactions_above_height( + &mut self, + block_height: BlockHeight, + ) -> Result<(), ApiServerStorageError> { + let mut conn = QueryFromConnection::new(self.connection.as_ref().expect(CONN_ERR)); + conn.del_token_transactions_above_height(block_height).await?; + + Ok(()) + } + async fn set_address_balance_at_height( &mut self, address: &Address, @@ -121,6 +131,25 @@ impl ApiServerStorageWrite for ApiServerPostgresTransactionalRw<'_> { Ok(()) } + async fn set_token_transaction_at_height( + &mut self, + token_id: TokenId, + transaction_id: Id, + block_height: BlockHeight, + tx_global_index: u64, + ) -> Result<(), ApiServerStorageError> { + let mut conn = QueryFromConnection::new(self.connection.as_ref().expect(CONN_ERR)); + conn.set_token_transactions_at_height( + token_id, + transaction_id, + block_height, + tx_global_index, + ) + .await?; + + Ok(()) + } + async fn set_mainchain_block( &mut self, block_id: Id, @@ -218,11 +247,11 @@ impl ApiServerStorageWrite for ApiServerPostgresTransactionalRw<'_> { &mut self, outpoint: UtxoOutPoint, utxo: Utxo, - address: &str, + addresses: &[&str], block_height: BlockHeight, ) -> Result<(), ApiServerStorageError> { let mut conn = QueryFromConnection::new(self.connection.as_ref().expect(CONN_ERR)); - conn.set_utxo_at_height(outpoint, utxo, address, block_height).await?; + conn.set_utxo_at_height(outpoint, utxo, addresses, block_height).await?; Ok(()) } @@ -231,11 +260,11 @@ impl ApiServerStorageWrite for ApiServerPostgresTransactionalRw<'_> { &mut self, outpoint: UtxoOutPoint, utxo: LockedUtxo, - address: &str, + addresses: &[&str], block_height: BlockHeight, ) -> Result<(), ApiServerStorageError> { let mut conn = QueryFromConnection::new(self.connection.as_ref().expect(CONN_ERR)); - conn.set_locked_utxo_at_height(outpoint, utxo, address, block_height).await?; + conn.set_locked_utxo_at_height(outpoint, utxo, addresses, block_height).await?; Ok(()) } @@ -434,6 +463,18 @@ impl ApiServerStorageRead for ApiServerPostgresTransactionalRw<'_> { Ok(res) } + async fn get_token_transactions( + &self, + token_id: TokenId, + len: u32, + tx_global_index: u64, + ) -> Result, ApiServerStorageError> { + let conn = QueryFromConnection::new(self.connection.as_ref().expect(CONN_ERR)); + let res = conn.get_token_transactions(token_id, len, tx_global_index).await?; + + Ok(res) + } + async fn get_latest_blocktimestamps( &self, ) -> Result, ApiServerStorageError> { diff --git a/api-server/api-server-common/src/storage/storage_api/mod.rs b/api-server/api-server-common/src/storage/storage_api/mod.rs index c71cbb512..09a67759d 100644 --- a/api-server/api-server-common/src/storage/storage_api/mod.rs +++ b/api-server/api-server-common/src/storage/storage_api/mod.rs @@ -68,6 +68,8 @@ pub enum ApiServerStorageError { AddressableError, #[error("Block timestamp too high: {0}")] TimestampTooHigh(BlockTimestamp), + #[error("Tx global index too hight: {0}")] + TxGlobalIndexTooHigh(u64), #[error("Id creation error: {0}")] IdCreationError(#[from] IdCreationError), } @@ -562,7 +564,7 @@ pub struct TransactionInfo { pub struct TransactionWithBlockInfo { pub tx_info: TransactionInfo, pub block_aux: BlockAuxData, - pub global_tx_index: u64, + pub tx_global_index: u64, } pub struct PoolBlockStats { @@ -581,6 +583,12 @@ pub struct AmountWithDecimals { pub decimals: u8, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TokenTransaction { + pub tx_global_index: u64, + pub tx_id: Id, +} + #[async_trait::async_trait] pub trait ApiServerStorageRead: Sync { async fn is_initialized(&self) -> Result; @@ -609,6 +617,16 @@ pub trait ApiServerStorageRead: Sync { address: &str, ) -> Result>, ApiServerStorageError>; + /// Returns a page of transaction IDs that reference this `token_id`, limited to `len` entries + /// and with a `tx_global_index` older than the specified value. + /// The `tx_global_index` and is not continuous for a specific `token_id`. + async fn get_token_transactions( + &self, + token_id: TokenId, + len: u32, + tx_global_index: u64, + ) -> Result, ApiServerStorageError>; + async fn get_best_block(&self) -> Result; async fn get_latest_blocktimestamps( @@ -806,6 +824,11 @@ pub trait ApiServerStorageWrite: ApiServerStorageRead { block_height: BlockHeight, ) -> Result<(), ApiServerStorageError>; + async fn del_token_transactions_above_height( + &mut self, + block_height: BlockHeight, + ) -> Result<(), ApiServerStorageError>; + async fn set_address_balance_at_height( &mut self, address: &Address, @@ -829,6 +852,17 @@ pub trait ApiServerStorageWrite: ApiServerStorageRead { block_height: BlockHeight, ) -> Result<(), ApiServerStorageError>; + /// Sets the `token_id`–`transaction_id` pair at the specified `block_height` along with the + /// `tx_global_index`. + /// If the pair already exists at that `block_height`, the `tx_global_index` is updated. + async fn set_token_transaction_at_height( + &mut self, + token_id: TokenId, + transaction_id: Id, + block_height: BlockHeight, + tx_global_index: u64, + ) -> Result<(), ApiServerStorageError>; + async fn set_mainchain_block( &mut self, block_id: Id, @@ -883,7 +917,7 @@ pub trait ApiServerStorageWrite: ApiServerStorageRead { &mut self, outpoint: UtxoOutPoint, utxo: Utxo, - address: &str, + addresses: &[&str], block_height: BlockHeight, ) -> Result<(), ApiServerStorageError>; @@ -891,7 +925,7 @@ pub trait ApiServerStorageWrite: ApiServerStorageRead { &mut self, outpoint: UtxoOutPoint, utxo: LockedUtxo, - address: &str, + addresses: &[&str], block_height: BlockHeight, ) -> Result<(), ApiServerStorageError>; diff --git a/api-server/scanner-lib/Cargo.toml b/api-server/scanner-lib/Cargo.toml index 19c253931..7d531dd5e 100644 --- a/api-server/scanner-lib/Cargo.toml +++ b/api-server/scanner-lib/Cargo.toml @@ -18,6 +18,7 @@ node-comm = { path = "../../wallet/wallet-node-client" } orders-accounting = { path = "../../orders-accounting" } pos-accounting = { path = "../../pos-accounting" } randomness = { path = "../../randomness" } +serialization = { path = "../../serialization" } tokens-accounting = { path = "../../tokens-accounting" } tx-verifier = { path = "../../chainstate/tx-verifier" } utils = { path = "../../utils" } @@ -28,10 +29,11 @@ thiserror.workspace = true tokio = { workspace = true, features = ["full"] } [dev-dependencies] -chainstate-storage = { path = "../../chainstate/storage", features = ["expensive-reads"] } +chainstate-storage = { path = "../../chainstate/storage", features = [ + "expensive-reads", +] } chainstate-test-framework = { path = "../../chainstate/test-framework" } crypto = { path = "../../crypto" } -serialization = { path = "../../serialization" } test-utils = { path = "../../test-utils" } ctor.workspace = true diff --git a/api-server/scanner-lib/src/blockchain_state/mod.rs b/api-server/scanner-lib/src/blockchain_state/mod.rs index e8f52456f..f47987be6 100644 --- a/api-server/scanner-lib/src/blockchain_state/mod.rs +++ b/api-server/scanner-lib/src/blockchain_state/mod.rs @@ -13,7 +13,14 @@ // See the License for the specific language governing permissions and // limitations under the License. -use crate::sync::local_state::LocalBlockchainState; +use std::{ + collections::{BTreeMap, BTreeSet}, + ops::{Add, Sub}, + sync::Arc, +}; + +use futures::{stream::FuturesOrdered, TryStreamExt}; + use api_server_common::storage::storage_api::{ block_aux_data::{BlockAuxData, BlockWithExtraData}, ApiServerStorage, ApiServerStorageError, ApiServerStorageRead, ApiServerStorageWrite, @@ -31,6 +38,10 @@ use common::{ config::ChainConfig, make_delegation_id, make_order_id, make_token_id, output_value::OutputValue, + signature::inputsig::{ + authorize_hashed_timelock_contract_spend::AuthorizedHashedTimelockContractSpend, + InputWitness, + }, tokens::{get_referenced_token_ids_ignore_issuance, IsTokenFrozen, TokenId, TokenIssuance}, transaction::OutPointSourceId, AccountCommand, AccountNonce, AccountSpending, Block, DelegationId, Destination, GenBlock, @@ -39,19 +50,16 @@ use common::{ }, primitives::{id::WithId, Amount, BlockHeight, CoinOrTokenId, Fee, Id, Idable, H256}, }; -use futures::{stream::FuturesOrdered, TryStreamExt}; use orders_accounting::OrderData; use pos_accounting::{PoSAccountingView, PoolData}; -use std::{ - collections::{BTreeMap, BTreeSet}, - ops::{Add, Sub}, - sync::Arc, -}; +use serialization::DecodeAll; use tokens_accounting::TokensAccountingView; use tx_verifier::transaction_verifier::{ calculate_tokens_burned_in_outputs, distribute_pos_reward, }; +use crate::sync::local_state::LocalBlockchainState; + mod pos_adapter; #[derive(Debug, thiserror::Error)] @@ -153,7 +161,7 @@ impl LocalBlockchainState for BlockchainState // Third, txs are flushed to the db AFTER the block. // This is done because transaction table has FOREIGN key `owning_block_id` referring block table. - for tx in block.transactions().iter() { + for (idx, tx) in block.transactions().iter().enumerate() { let (tx_fee, tx_additional_info) = calculate_tx_fee_and_collect_token_info( &self.chain_config, &mut db_tx, @@ -171,6 +179,7 @@ impl LocalBlockchainState for BlockchainState (block_height, block_timestamp), new_median_time, tx, + next_order_number + idx as u64, ) .await .expect("Unable to update tables from transaction"); @@ -299,7 +308,9 @@ async fn update_locked_amounts_for_current_block( let address = Address::::new(chain_config, destination.clone()) .expect("Unable to encode destination"); let utxo = Utxo::new_with_info(locked_utxo, None); - db_tx.set_utxo_at_height(outpoint, utxo, address.as_str(), block_height).await?; + db_tx + .set_utxo_at_height(outpoint, utxo, &[address.as_str()], block_height) + .await?; } } @@ -331,6 +342,8 @@ async fn disconnect_tables_above_height( db_tx.del_address_transactions_above_height(block_height).await?; + db_tx.del_token_transactions_above_height(block_height).await?; + db_tx.del_utxo_above_height(block_height).await?; db_tx.del_locked_utxo_above_height(block_height).await?; @@ -1002,13 +1015,14 @@ async fn update_tables_from_transaction( (block_height, block_timestamp): (BlockHeight, BlockTimestamp), median_time: BlockTimestamp, transaction: &SignedTransaction, + tx_global_index: u64, ) -> Result<(), ApiServerStorageError> { update_tables_from_transaction_inputs( Arc::clone(&chain_config), db_tx, block_height, - transaction.transaction().inputs(), - transaction.transaction(), + transaction, + tx_global_index, ) .await .expect("Unable to update tables from transaction inputs"); @@ -1018,9 +1032,8 @@ async fn update_tables_from_transaction( db_tx, (block_height, block_timestamp), median_time, - transaction.transaction().get_id(), - transaction.transaction().inputs(), - transaction.transaction().outputs(), + transaction.transaction(), + tx_global_index, ) .await .expect("Unable to update tables from transaction outputs"); @@ -1032,13 +1045,31 @@ async fn update_tables_from_transaction_inputs( chain_config: Arc, db_tx: &mut T, block_height: BlockHeight, - inputs: &[TxInput], - tx: &Transaction, + tx: &SignedTransaction, + tx_global_index: u64, ) -> Result<(), ApiServerStorageError> { + let sigs = tx.signatures(); + let tx = tx.transaction(); let mut address_transactions: BTreeMap, BTreeSet>> = BTreeMap::new(); + let mut transaction_tokens: BTreeSet = BTreeSet::new(); - for input in inputs { + let update_tokens_in_transaction = |order: &Order, tokens_in_transaction: &mut BTreeSet<_>| { + match order.give_currency { + CoinOrTokenId::TokenId(token_id) => { + tokens_in_transaction.insert(token_id); + } + CoinOrTokenId::Coin => {} + } + match order.ask_currency { + CoinOrTokenId::TokenId(token_id) => { + tokens_in_transaction.insert(token_id); + } + CoinOrTokenId::Coin => {} + } + }; + + for (input, sig) in tx.inputs().iter().zip(sigs) { match input { TxInput::AccountCommand(nonce, cmd) => match cmd { AccountCommand::MintTokens(token_id, amount) => { @@ -1072,6 +1103,7 @@ async fn update_tables_from_transaction_inputs( block_height, ) .await; + transaction_tokens.insert(*token_id); } AccountCommand::UnmintTokens(token_id) => { let total_burned = @@ -1099,6 +1131,7 @@ async fn update_tables_from_transaction_inputs( block_height, ) .await; + transaction_tokens.insert(*token_id); } AccountCommand::FreezeToken(token_id, is_unfreezable) => { let issuance = @@ -1123,6 +1156,7 @@ async fn update_tables_from_transaction_inputs( block_height, ) .await; + transaction_tokens.insert(*token_id); } AccountCommand::UnfreezeToken(token_id) => { let issuance = @@ -1147,6 +1181,7 @@ async fn update_tables_from_transaction_inputs( block_height, ) .await; + transaction_tokens.insert(*token_id); } AccountCommand::LockTokenSupply(token_id) => { let issuance = @@ -1171,6 +1206,7 @@ async fn update_tables_from_transaction_inputs( block_height, ) .await; + transaction_tokens.insert(*token_id); } AccountCommand::ChangeTokenAuthority(token_id, destination) => { let issuance = @@ -1195,6 +1231,7 @@ async fn update_tables_from_transaction_inputs( block_height, ) .await; + transaction_tokens.insert(*token_id); } AccountCommand::ChangeTokenMetadataUri(token_id, metadata_uri) => { let issuance = @@ -1219,6 +1256,7 @@ async fn update_tables_from_transaction_inputs( block_height, ) .await; + transaction_tokens.insert(*token_id); } AccountCommand::FillOrder(order_id, fill_amount_in_ask_currency, _) => { let order = db_tx.get_order(*order_id).await?.expect("must exist"); @@ -1226,12 +1264,16 @@ async fn update_tables_from_transaction_inputs( order.fill(&chain_config, block_height, *fill_amount_in_ask_currency); db_tx.set_order_at_height(*order_id, &order, block_height).await?; + + update_tokens_in_transaction(&order, &mut transaction_tokens); } AccountCommand::ConcludeOrder(order_id) => { let order = db_tx.get_order(*order_id).await?.expect("must exist"); let order = order.conclude(); db_tx.set_order_at_height(*order_id, &order, block_height).await?; + + update_tokens_in_transaction(&order, &mut transaction_tokens); } }, TxInput::OrderAccountCommand(cmd) => match cmd { @@ -1241,12 +1283,14 @@ async fn update_tables_from_transaction_inputs( order.fill(&chain_config, block_height, *fill_amount_in_ask_currency); db_tx.set_order_at_height(*order_id, &order, block_height).await?; + update_tokens_in_transaction(&order, &mut transaction_tokens); } OrderAccountCommand::ConcludeOrder(order_id) => { let order = db_tx.get_order(*order_id).await?.expect("must exist"); let order = order.conclude(); db_tx.set_order_at_height(*order_id, &order, block_height).await?; + update_tokens_in_transaction(&order, &mut transaction_tokens); } OrderAccountCommand::FreezeOrder(order_id) => { let order = db_tx.get_order(*order_id).await?.expect("must exist"); @@ -1414,16 +1458,41 @@ async fn update_tables_from_transaction_inputs( block_height, ) .await; + transaction_tokens.insert(token_id); } - TxOutput::Htlc(_, htlc) => { - let address = - Address::::new(&chain_config, htlc.spend_key) - .expect("Unable to encode destination"); + TxOutput::Htlc(output_value, htlc) => { + let address = if let InputWitness::Standard(sig) = sig { + let htlc_sig = AuthorizedHashedTimelockContractSpend::decode_all( + &mut sig.raw_signature(), + ) + .expect("proper signature"); + + let dest = match htlc_sig { + AuthorizedHashedTimelockContractSpend::Spend(_, _) => { + htlc.spend_key + } + AuthorizedHashedTimelockContractSpend::Refund(_) => { + htlc.refund_key + } + }; + Address::::new(&chain_config, dest) + .expect("Unable to encode destination") + } else { + panic!("Empty signature for htlc") + }; address_transactions .entry(address.clone()) .or_default() .insert(tx.get_id()); + + match output_value { + OutputValue::TokenV0(_) => {} + OutputValue::TokenV1(token_id, _) => { + transaction_tokens.insert(token_id); + } + OutputValue::Coin(_) => {} + } } TxOutput::LockThenTransfer(output_value, destination, _) | TxOutput::Transfer(output_value, destination) => { @@ -1446,6 +1515,7 @@ async fn update_tables_from_transaction_inputs( block_height, ) .await; + transaction_tokens.insert(token_id); } OutputValue::Coin(amount) => { decrease_address_amount( @@ -1465,13 +1535,9 @@ async fn update_tables_from_transaction_inputs( } } - for address_transaction in address_transactions { + for (address, transactions) in address_transactions { db_tx - .set_address_transactions_at_height( - address_transaction.0.as_str(), - address_transaction.1.into_iter().collect(), - block_height, - ) + .set_address_transactions_at_height(address.as_str(), transactions, block_height) .await .map_err(|_| { ApiServerStorageError::LowLevelStorageError( @@ -1479,6 +1545,17 @@ async fn update_tables_from_transaction_inputs( ) })?; } + let tx_id = tx.get_id(); + for token_id in transaction_tokens { + db_tx + .set_token_transaction_at_height(token_id, tx_id, block_height, tx_global_index) + .await + .map_err(|_| { + ApiServerStorageError::LowLevelStorageError( + "Unable to set token transactions".to_string(), + ) + })?; + } Ok(()) } @@ -1488,15 +1565,18 @@ async fn update_tables_from_transaction_outputs( db_tx: &mut T, (block_height, block_timestamp): (BlockHeight, BlockTimestamp), median_time: BlockTimestamp, - transaction_id: Id, - inputs: &[TxInput], - outputs: &[TxOutput], + transaction: &Transaction, + tx_global_index: u64, ) -> Result<(), ApiServerStorageError> { + let tx_id = transaction.get_id(); + let inputs = transaction.inputs(); + let outputs = transaction.outputs(); let mut address_transactions: BTreeMap, BTreeSet>> = BTreeMap::new(); + let mut transaction_tokens: BTreeSet = BTreeSet::new(); for (idx, output) in outputs.iter().enumerate() { - let outpoint = UtxoOutPoint::new(OutPointSourceId::Transaction(transaction_id), idx as u32); + let outpoint = UtxoOutPoint::new(OutPointSourceId::Transaction(tx_id), idx as u32); match output { TxOutput::Burn(value) => { let (coin_or_token_id, amount) = match value { @@ -1505,6 +1585,7 @@ async fn update_tables_from_transaction_outputs( continue; } OutputValue::TokenV1(token_id, amount) => { + transaction_tokens.insert(*token_id); (CoinOrTokenId::TokenId(*token_id), amount) } }; @@ -1578,11 +1659,13 @@ async fn update_tables_from_transaction_outputs( block_height, ) .await; + transaction_tokens.insert(token_id); } TxOutput::IssueNft(token_id, issuance, destination) => { let address = Address::::new(&chain_config, destination.clone()) .expect("Unable to encode destination"); - address_transactions.entry(address.clone()).or_default().insert(transaction_id); + address_transactions.entry(address.clone()).or_default().insert(tx_id); + transaction_tokens.insert(*token_id); db_tx .set_nft_token_issuance(*token_id, block_height, *issuance.clone(), destination) @@ -1683,12 +1766,12 @@ async fn update_tables_from_transaction_outputs( stake_pool_data.decommission_key().clone(), ) .expect("Unable to encode address"); - address_transactions.entry(address.clone()).or_default().insert(transaction_id); + address_transactions.entry(address.clone()).or_default().insert(tx_id); let staker_address = Address::::new(&chain_config, stake_pool_data.staker().clone()) .expect("Unable to encode address"); - address_transactions.entry(staker_address).or_default().insert(transaction_id); + address_transactions.entry(staker_address).or_default().insert(tx_id); } TxOutput::DelegateStaking(amount, delegation_id) => { // Update delegation pledge @@ -1732,13 +1815,13 @@ async fn update_tables_from_transaction_outputs( new_delegation.spend_destination().clone(), ) .expect("Unable to encode address"); - address_transactions.entry(address.clone()).or_default().insert(transaction_id); + address_transactions.entry(address.clone()).or_default().insert(tx_id); } TxOutput::Transfer(output_value, destination) => { let address = Address::::new(&chain_config, destination.clone()) .expect("Unable to encode destination"); - address_transactions.entry(address.clone()).or_default().insert(transaction_id); + address_transactions.entry(address.clone()).or_default().insert(tx_id); let token_decimals = match output_value { OutputValue::TokenV0(_) => None, @@ -1751,6 +1834,7 @@ async fn update_tables_from_transaction_outputs( block_height, ) .await; + transaction_tokens.insert(*token_id); Some(token_decimals(*token_id, &BTreeMap::new(), db_tx).await?.1) } OutputValue::Coin(amount) => { @@ -1766,11 +1850,10 @@ async fn update_tables_from_transaction_outputs( } }; - let outpoint = - UtxoOutPoint::new(OutPointSourceId::Transaction(transaction_id), idx as u32); + let outpoint = UtxoOutPoint::new(OutPointSourceId::Transaction(tx_id), idx as u32); let utxo = Utxo::new(output.clone(), token_decimals, None); db_tx - .set_utxo_at_height(outpoint, utxo, address.as_str(), block_height) + .set_utxo_at_height(outpoint, utxo, &[address.as_str()], block_height) .await .expect("Unable to set utxo"); } @@ -1778,9 +1861,8 @@ async fn update_tables_from_transaction_outputs( let address = Address::::new(&chain_config, destination.clone()) .expect("Unable to encode destination"); - address_transactions.entry(address.clone()).or_default().insert(transaction_id); - let outpoint = - UtxoOutPoint::new(OutPointSourceId::Transaction(transaction_id), idx as u32); + address_transactions.entry(address.clone()).or_default().insert(tx_id); + let outpoint = UtxoOutPoint::new(OutPointSourceId::Transaction(tx_id), idx as u32); let already_unlocked = tx_verifier::timelock_check::check_timelock( &block_height, @@ -1817,6 +1899,7 @@ async fn update_tables_from_transaction_outputs( } OutputValue::TokenV0(_) => None, OutputValue::TokenV1(token_id, amount) => { + transaction_tokens.insert(*token_id); if already_unlocked { increase_address_amount( db_tx, @@ -1843,44 +1926,64 @@ async fn update_tables_from_transaction_outputs( if already_unlocked { let utxo = Utxo::new(output.clone(), token_decimals, None); db_tx - .set_utxo_at_height(outpoint, utxo, address.as_str(), block_height) + .set_utxo_at_height(outpoint, utxo, &[address.as_str()], block_height) .await .expect("Unable to set utxo"); } else { let lock = UtxoLock::from_output_lock(*lock, block_timestamp, block_height); let utxo = LockedUtxo::new(output.clone(), token_decimals, lock); db_tx - .set_locked_utxo_at_height(outpoint, utxo, address.as_str(), block_height) + .set_locked_utxo_at_height( + outpoint, + utxo, + &[address.as_str()], + block_height, + ) .await .expect("Unable to set locked utxo"); } } TxOutput::Htlc(output_value, htlc) => { - let address = Address::::new(&chain_config, htlc.spend_key.clone()) - .expect("Unable to encode destination"); + let spend_address = + Address::::new(&chain_config, htlc.spend_key.clone()) + .expect("Unable to encode destination"); + + address_transactions.entry(spend_address.clone()).or_default().insert(tx_id); - address_transactions.entry(address.clone()).or_default().insert(transaction_id); + let refund_address = + Address::::new(&chain_config, htlc.refund_key.clone()) + .expect("Unable to encode destination"); + + address_transactions.entry(refund_address.clone()).or_default().insert(tx_id); let token_decimals = match output_value { OutputValue::Coin(_) | OutputValue::TokenV0(_) => None, OutputValue::TokenV1(token_id, _) => { + transaction_tokens.insert(*token_id); Some(token_decimals(*token_id, &BTreeMap::new(), db_tx).await?.1) } }; - let outpoint = - UtxoOutPoint::new(OutPointSourceId::Transaction(transaction_id), idx as u32); + let outpoint = UtxoOutPoint::new(OutPointSourceId::Transaction(tx_id), idx as u32); let utxo = Utxo::new(output.clone(), token_decimals, None); db_tx - .set_utxo_at_height(outpoint, utxo, address.as_str(), block_height) + .set_utxo_at_height( + outpoint, + utxo, + &[spend_address.as_str(), refund_address.as_str()], + block_height, + ) .await .expect("Unable to set utxo"); } TxOutput::CreateOrder(order_data) => { let order_id = make_order_id(inputs)?; - let amount_and_currency = |v: &OutputValue| match v { + let mut amount_and_currency = |v: &OutputValue| match v { OutputValue::Coin(amount) => (CoinOrTokenId::Coin, *amount), - OutputValue::TokenV1(id, amount) => (CoinOrTokenId::TokenId(*id), *amount), + OutputValue::TokenV1(id, amount) => { + transaction_tokens.insert(*id); + (CoinOrTokenId::TokenId(*id), *amount) + } OutputValue::TokenV0(_) => panic!("unsupported token"), }; @@ -1908,13 +2011,9 @@ async fn update_tables_from_transaction_outputs( } } - for address_transaction in address_transactions { + for (address, transactions) in address_transactions { db_tx - .set_address_transactions_at_height( - address_transaction.0.as_str(), - address_transaction.1, - block_height, - ) + .set_address_transactions_at_height(address.as_str(), transactions, block_height) .await .map_err(|_| { ApiServerStorageError::LowLevelStorageError( @@ -1923,6 +2022,17 @@ async fn update_tables_from_transaction_outputs( })?; } + for token_id in transaction_tokens { + db_tx + .set_token_transaction_at_height(token_id, tx_id, block_height, tx_global_index) + .await + .map_err(|_| { + ApiServerStorageError::LowLevelStorageError( + "Unable to set token transactions".to_string(), + ) + })?; + } + Ok(()) } @@ -2080,7 +2190,7 @@ async fn set_utxo( let address = Address::::new(chain_config, destination.clone()) .expect("Unable to encode destination"); db_tx - .set_utxo_at_height(outpoint, utxo, address.as_str(), block_height) + .set_utxo_at_height(outpoint, utxo, &[address.as_str()], block_height) .await .expect("Unable to set utxo"); } diff --git a/api-server/scanner-lib/src/sync/tests/mod.rs b/api-server/scanner-lib/src/sync/tests/mod.rs index 638c03bff..4f1fc1d1f 100644 --- a/api-server/scanner-lib/src/sync/tests/mod.rs +++ b/api-server/scanner-lib/src/sync/tests/mod.rs @@ -37,8 +37,10 @@ use chainstate_test_framework::{TestFramework, TransactionBuilder}; use common::{ address::Address, chain::{ - make_delegation_id, + htlc::{HashedTimelockContract, HtlcSecret}, + make_delegation_id, make_order_id, make_token_id, output_value::OutputValue, + signature::inputsig::authorize_hashed_timelock_contract_spend::AuthorizedHashedTimelockContractSpend, signature::{ inputsig::{ authorize_pubkey_spend::sign_public_key_spending, @@ -55,8 +57,10 @@ use common::{ }, stakelock::StakePoolData, timelock::OutputTimeLock, - CoinUnit, Destination, OrderId, OutPointSourceId, PoolId, SignedTransaction, TxInput, - TxOutput, UtxoOutPoint, + tokens::{IsTokenUnfreezable, TokenIssuance}, + AccountCommand, AccountNonce, CoinUnit, Destination, OrderAccountCommand, OrderData, + OrderId, OutPointSourceId, PoolId, SignedTransaction, Transaction, TxInput, TxOutput, + UtxoOutPoint, }, primitives::{per_thousand::PerThousand, Amount, CoinOrTokenId, Idable, H256}, }; @@ -1257,3 +1261,663 @@ async fn check_all_destinations_are_tracked(#[case] seed: Seed) { assert_eq!(utxos.len(), 2); } } + +#[rstest] +#[trace] +#[case(test_utils::random::Seed::from_entropy())] +#[tokio::test] +async fn token_transactions_storage_check(#[case] seed: Seed) { + let mut rng = make_seedable_rng(seed); + + let mut tf = TestFramework::builder(&mut rng).build(); + let chain_config = Arc::clone(tf.chainstate.get_chain_config()); + + let storage = { + let mut storage = TransactionalApiServerInMemoryStorage::new(&chain_config); + let mut db_tx = storage.transaction_rw().await.unwrap(); + db_tx.reinitialize_storage(&chain_config).await.unwrap(); + db_tx.commit().await.unwrap(); + storage + }; + let mut local_state = BlockchainState::new(chain_config.clone(), storage); + local_state.scan_genesis(chain_config.genesis_block().as_ref()).await.unwrap(); + + let target_block_time = chain_config.target_block_spacing(); + let genesis_id = chain_config.genesis_block_id(); + let mut coins_amount = Amount::from_atoms(100_000_000_000_000); + + // ------------------------------------------------------------------------ + // 1. Setup: Issue a Token and Mint it + // ------------------------------------------------------------------------ + // 1a. Issue Token + let tx_issue = TransactionBuilder::new() + .add_input( + TxInput::from_utxo(OutPointSourceId::BlockReward(genesis_id), 0), + InputWitness::NoSignature(None), + ) + .add_output(TxOutput::IssueFungibleToken(Box::new(TokenIssuance::V1( + common::chain::tokens::TokenIssuanceV1 { + token_ticker: "TEST".as_bytes().to_vec(), + number_of_decimals: 2, + metadata_uri: "http://uri".as_bytes().to_vec(), + total_supply: common::chain::tokens::TokenTotalSupply::Unlimited, + authority: Destination::AnyoneCanSpend, + is_freezable: common::chain::tokens::IsTokenFreezable::Yes, + }, + )))) + .add_output(TxOutput::Transfer( + OutputValue::Coin(coins_amount), + Destination::AnyoneCanSpend, + )) + .build(); + + let token_id = make_token_id(&chain_config, BlockHeight::one(), tx_issue.inputs()).unwrap(); + let tx_issue_id = tx_issue.transaction().get_id(); + + let block1 = tf + .make_block_builder() + .with_parent(genesis_id) + .with_transactions(vec![tx_issue.clone()]) + .build(&mut rng); + tf.process_block(block1.clone(), BlockSource::Local).unwrap(); + local_state.scan_blocks(BlockHeight::new(1), vec![block1]).await.unwrap(); + + // 1b. Mint Token (Account Command) + // We mint to an address we control so we can spend it later as an input + let nonce = AccountNonce::new(0); + let mint_amount = Amount::from_atoms(1000); + + // Construct Mint Tx + let token_supply_change_fee = chain_config.token_supply_change_fee(BlockHeight::one()); + eprintln!("amounts: {coins_amount:?} {token_supply_change_fee:?}"); + coins_amount = (coins_amount - token_supply_change_fee).unwrap(); + let tx_mint = TransactionBuilder::new() + .add_input( + TxInput::from_utxo(OutPointSourceId::Transaction(tx_issue_id), 1), + InputWitness::NoSignature(None), + ) + .add_input( + TxInput::AccountCommand(nonce, AccountCommand::MintTokens(token_id, mint_amount)), + InputWitness::NoSignature(None), + ) + .add_output(TxOutput::Transfer( + OutputValue::Coin(coins_amount), + Destination::AnyoneCanSpend, + )) + .add_output(TxOutput::Transfer( + OutputValue::TokenV1(token_id, mint_amount), + Destination::AnyoneCanSpend, + )) + .build(); + + let tx_mint_id = tx_mint.transaction().get_id(); + + let best_block_id = tf.best_block_id(); + let block2 = tf + .make_block_builder() + .with_parent(best_block_id) + .with_transactions(vec![tx_mint]) + .build(&mut rng); + tf.process_block(block2.clone(), BlockSource::Local).unwrap(); + local_state.scan_blocks(BlockHeight::new(2), vec![block2]).await.unwrap(); + + // Check count: Issue(1) + Mint(1) = 2 + let db_tx = local_state.storage().transaction_ro().await.unwrap(); + let txs = db_tx.get_token_transactions(token_id, 100, u64::MAX).await.unwrap(); + assert_eq!(txs.len(), 2); + drop(db_tx); + assert!(txs.iter().any(|t| t.tx_id == tx_issue_id)); + assert!(txs.iter().any(|t| t.tx_id == tx_mint_id)); + + // ------------------------------------------------------------------------ + // 2. Token Authority Management Commands + // ------------------------------------------------------------------------ + + // Helper to create simple command txs + + let mut current_nonce = nonce.increment().unwrap(); + + // 2a. Freeze Token + let token_freeze_fee = chain_config.token_freeze_fee(BlockHeight::one()); + coins_amount = (coins_amount - token_freeze_fee).unwrap(); + let tx_freeze = create_command_tx( + current_nonce, + AccountCommand::FreezeToken(token_id, IsTokenUnfreezable::Yes), + tx_mint_id, + coins_amount, + ); + let tx_freeze_id = tx_freeze.transaction().get_id(); + current_nonce = current_nonce.increment().unwrap(); + + // 2b. Unfreeze Token + coins_amount = (coins_amount - token_freeze_fee).unwrap(); + let tx_unfreeze = create_command_tx( + current_nonce, + AccountCommand::UnfreezeToken(token_id), + tx_freeze_id, + coins_amount, + ); + let tx_unfreeze_id = tx_unfreeze.transaction().get_id(); + current_nonce = current_nonce.increment().unwrap(); + + // 2c. Change Metadata + let token_change_metadata_uri_fee = chain_config.token_change_metadata_uri_fee(); + coins_amount = (coins_amount - token_change_metadata_uri_fee).unwrap(); + let tx_metadata = create_command_tx( + current_nonce, + AccountCommand::ChangeTokenMetadataUri(token_id, "http://new-uri".as_bytes().to_vec()), + tx_unfreeze_id, + coins_amount, + ); + let tx_metadata_id = tx_metadata.transaction().get_id(); + current_nonce = current_nonce.increment().unwrap(); + + // 2d. Change Authority + let token_change_authority_fee = chain_config.token_change_authority_fee(BlockHeight::new(3)); + coins_amount = (coins_amount - token_change_authority_fee).unwrap(); + let (_new_auth_sk, new_auth_pk) = PrivateKey::new_from_rng(&mut rng, KeyKind::Secp256k1Schnorr); + let new_auth_dest = Destination::PublicKey(new_auth_pk); + let tx_authority = create_command_tx( + current_nonce, + AccountCommand::ChangeTokenAuthority(token_id, new_auth_dest), + tx_metadata_id, + coins_amount, + ); + let tx_authority_id = tx_authority.transaction().get_id(); + + eprintln!("{tx_mint_id:?}, {tx_freeze_id:?}, {tx_unfreeze_id:?}, {tx_metadata_id:?}, {tx_authority_id}"); + // Process Block 3 with all management commands + tf.progress_time_seconds_since_epoch(target_block_time.as_secs()); + let best_block_id = tf.best_block_id(); + let block3 = tf + .make_block_builder() + .with_parent(best_block_id) + .with_transactions(vec![ + tx_freeze.clone(), + tx_unfreeze.clone(), + tx_metadata.clone(), + tx_authority.clone(), + ]) + .build(&mut rng); + tf.process_block(block3.clone(), BlockSource::Local).unwrap(); + local_state.scan_blocks(BlockHeight::new(3), vec![block3]).await.unwrap(); + + // Verify Storage: 2 previous + 4 new = 6 transactions + let db_tx = local_state.storage().transaction_ro().await.unwrap(); + let txs = db_tx.get_token_transactions(token_id, 100, u64::MAX).await.unwrap(); + assert_eq!(txs.len(), 6); + let ids: Vec<_> = txs.iter().map(|t| t.tx_id).collect(); + assert!(ids.contains(&tx_freeze_id)); + assert!(ids.contains(&tx_unfreeze_id)); + assert!(ids.contains(&tx_metadata_id)); + assert!(ids.contains(&tx_authority_id)); + drop(db_tx); + + // ------------------------------------------------------------------------ + // 3. Input Spending (Using Token as Input) + // ------------------------------------------------------------------------ + + // We spend the output from Block 2 (Mint) which holds tokens. + let tx_spend = TransactionBuilder::new() + .add_input( + TxInput::from_utxo(OutPointSourceId::Transaction(tx_mint_id), 1), + InputWitness::NoSignature(None), + ) + .add_output(TxOutput::Transfer( + OutputValue::TokenV1(token_id, mint_amount), + Destination::AnyoneCanSpend, + )) + .build(); + let tx_spend_id = tx_spend.transaction().get_id(); + + // Process Block 4 + tf.progress_time_seconds_since_epoch(target_block_time.as_secs()); + let best_block_id = tf.best_block_id(); + let block4 = tf + .make_block_builder() + .with_parent(best_block_id) + .with_transactions(vec![tx_spend.clone()]) + .build(&mut rng); + tf.process_block(block4.clone(), BlockSource::Local).unwrap(); + local_state.scan_blocks(BlockHeight::new(4), vec![block4]).await.unwrap(); + + // Verify Storage: 6 previous + 1 spend = 7 + let db_tx = local_state.storage().transaction_ro().await.unwrap(); + let txs = db_tx.get_token_transactions(token_id, 100, u64::MAX).await.unwrap(); + assert_eq!(txs.len(), 7); + assert!(txs.iter().any(|t| t.tx_id == tx_spend_id)); + drop(db_tx); + + // ------------------------------------------------------------------------ + // 4. Orders (Create, Fill, Conclude) + // ------------------------------------------------------------------------ + + // 4a. Create Order + // We want to sell our Tokens (Ask Coin, Give Token). + + // Order: Give 500 Token, Ask 500 Coins. + let give_amount = Amount::from_atoms(500); + let ask_amount = Amount::from_atoms(500); + + let order_data = OrderData::new( + Destination::AnyoneCanSpend, + OutputValue::Coin(ask_amount), + OutputValue::TokenV1(token_id, give_amount), + ); + + // Note: The input has 1000 tokens. We give 500 to order, keep 500 change. + let tx_create_order = TransactionBuilder::new() + .add_input( + TxInput::from_utxo(OutPointSourceId::Transaction(tx_spend_id), 0), + InputWitness::NoSignature(None), + ) + .add_output(TxOutput::CreateOrder(Box::new(order_data))) + .add_output(TxOutput::Transfer( + OutputValue::TokenV1(token_id, Amount::from_atoms(500)), + Destination::AnyoneCanSpend, + )) + .build(); + let tx_create_order_id = tx_create_order.transaction().get_id(); + let order_id = make_order_id(tx_create_order.inputs()).unwrap(); + + // Process Block 5 + tf.progress_time_seconds_since_epoch(target_block_time.as_secs()); + let best_block_id = tf.best_block_id(); + let block5 = tf + .make_block_builder() + .with_parent(best_block_id) + .with_transactions(vec![tx_create_order.clone()]) + .build(&mut rng); + tf.process_block(block5.clone(), BlockSource::Local).unwrap(); + local_state.scan_blocks(BlockHeight::new(5), vec![block5]).await.unwrap(); + + // Verify Storage: Order creation involves the token (in 'Give'), so it should be indexed. + let db_tx = local_state.storage().transaction_ro().await.unwrap(); + let txs = db_tx.get_token_transactions(token_id, 100, u64::MAX).await.unwrap(); + // 7 prev + 1 creation = 8 + assert_eq!(txs.len(), 8); + assert!(txs.iter().any(|t| t.tx_id == tx_create_order_id)); + drop(db_tx); + + // 4b. Fill Order + // Someone fills the order by paying Coins (Ask). + // For the Token ID index, this transaction is relevant because the Order involves the Token. + // The code `calculate_tx_fee_and_collect_token_info` and `update_tables_from_transaction_inputs` + // checks `OrderAccountCommand::FillOrder`, loads the order, checks currencies, and adds the tx. + + let fill_amount = Amount::from_atoms(100); // Partial fill + + coins_amount = (coins_amount - fill_amount).unwrap(); + let tx_fill = TransactionBuilder::new() + .add_input( + TxInput::from_utxo(OutPointSourceId::Transaction(tx_authority_id), 0), + InputWitness::NoSignature(None), + ) + .add_input( + TxInput::OrderAccountCommand(OrderAccountCommand::FillOrder(order_id, fill_amount)), + InputWitness::NoSignature(None), + ) + .add_output(TxOutput::Transfer( + OutputValue::Coin(coins_amount), + Destination::AnyoneCanSpend, + )) + .build(); + let tx_fill_id = tx_fill.transaction().get_id(); + + // Process Block 6 + tf.progress_time_seconds_since_epoch(target_block_time.as_secs()); + let best_block_id = tf.best_block_id(); + let block6 = tf + .make_block_builder() + .with_parent(best_block_id) + .with_transactions(vec![tx_fill.clone()]) + .build(&mut rng); + tf.process_block(block6.clone(), BlockSource::Local).unwrap(); + local_state.scan_blocks(BlockHeight::new(6), vec![block6]).await.unwrap(); + + // Verify Storage: Fill Order should be indexed for the token + let db_tx = local_state.storage().transaction_ro().await.unwrap(); + let txs = db_tx.get_token_transactions(token_id, 100, u64::MAX).await.unwrap(); + // 8 prev + 1 fill = 9 + assert_eq!(txs.len(), 9); + assert!(txs.iter().any(|t| t.tx_id == tx_fill_id)); + drop(db_tx); + + // 4c. Conclude Order + let tx_conclude = TransactionBuilder::new() + .add_input( + TxInput::from_utxo(OutPointSourceId::Transaction(tx_fill_id), 0), + InputWitness::NoSignature(None), + ) + .add_input( + TxInput::OrderAccountCommand(OrderAccountCommand::ConcludeOrder(order_id)), + InputWitness::NoSignature(None), + ) + .add_output(TxOutput::Transfer( + OutputValue::Coin(Amount::from_atoms(80000)), + Destination::AnyoneCanSpend, + )) + .build(); + let tx_conclude_id = tx_conclude.transaction().get_id(); + + // Process Block 7 + tf.progress_time_seconds_since_epoch(target_block_time.as_secs()); + let best_block_id = tf.best_block_id(); + let block7 = tf + .make_block_builder() + .with_parent(best_block_id) + .with_transactions(vec![tx_conclude.clone()]) + .build(&mut rng); + tf.process_block(block7.clone(), BlockSource::Local).unwrap(); + local_state.scan_blocks(BlockHeight::new(7), vec![block7]).await.unwrap(); + + // Verify Storage: Conclude Order should be indexed for the token + let db_tx = local_state.storage().transaction_ro().await.unwrap(); + let txs = db_tx.get_token_transactions(token_id, 100, u64::MAX).await.unwrap(); + // 9 prev + 1 conclude = 10 + assert_eq!(txs.len(), 10); + assert!(txs.iter().any(|t| t.tx_id == tx_conclude_id)); + drop(db_tx); +} + +#[rstest] +#[trace] +#[case(test_utils::random::Seed::from_entropy())] +#[tokio::test] +async fn htlc_addresses_storage_check(#[case] seed: Seed) { + let mut rng = make_seedable_rng(seed); + + let mut tf = TestFramework::builder(&mut rng).build(); + let chain_config = Arc::clone(tf.chainstate.get_chain_config()); + + // Initialize Storage and BlockchainState + let storage = { + let mut storage = TransactionalApiServerInMemoryStorage::new(&chain_config); + let mut db_tx = storage.transaction_rw().await.unwrap(); + db_tx.reinitialize_storage(&chain_config).await.unwrap(); + db_tx.commit().await.unwrap(); + storage + }; + let mut local_state = BlockchainState::new(chain_config.clone(), storage); + local_state.scan_genesis(chain_config.genesis_block().as_ref()).await.unwrap(); + + let target_block_time = chain_config.target_block_spacing(); + let genesis_id = chain_config.genesis_block_id(); + + // Create Spend and Refund destinations + let (spend_sk, spend_pk) = PrivateKey::new_from_rng(&mut rng, KeyKind::Secp256k1Schnorr); + let spend_dest = Destination::PublicKey(spend_pk.clone()); + + let (refund_sk, refund_pk) = PrivateKey::new_from_rng(&mut rng, KeyKind::Secp256k1Schnorr); + let refund_dest = Destination::PublicKey(refund_pk.clone()); + + // Construct HTLC Data + let secret = HtlcSecret::new_from_rng(&mut rng); + let secret_hash = secret.hash(); + + let htlc_data = HashedTimelockContract { + secret_hash, + spend_key: spend_dest.clone(), + refund_key: refund_dest.clone(), + refund_timelock: OutputTimeLock::ForBlockCount(1), + }; + + // Create Transaction with 2 HTLC outputs + let tx_fund = TransactionBuilder::new() + .add_input( + TxInput::from_utxo(OutPointSourceId::BlockReward(genesis_id), 0), + InputWitness::NoSignature(None), + ) + .add_output(TxOutput::Htlc( + OutputValue::Coin(Amount::from_atoms(1000)), + Box::new(htlc_data.clone()), + )) + .add_output(TxOutput::Htlc( + OutputValue::Coin(Amount::from_atoms(1000)), + Box::new(htlc_data), + )) + .build(); + + let tx_fund_id = tx_fund.transaction().get_id(); + + // Create and Process Block + tf.progress_time_seconds_since_epoch(target_block_time.as_secs()); + let block = tf + .make_block_builder() + .with_parent(genesis_id) + .with_transactions(vec![tx_fund.clone()]) + .build(&mut rng); + + tf.process_block(block.clone(), BlockSource::Local).unwrap(); + local_state.scan_blocks(BlockHeight::new(1), vec![block]).await.unwrap(); + + // Verify Storage + let db_tx = local_state.storage().transaction_ro().await.unwrap(); + + // Check Spend Address Transactions + let spend_address = Address::new(&chain_config, spend_dest).unwrap(); + let spend_txs = db_tx.get_address_transactions(spend_address.as_str()).await.unwrap(); + assert!( + spend_txs.contains(&tx_fund_id), + "Spend address should track the transaction" + ); + + // Check Refund Address Transactions + let refund_address = Address::new(&chain_config, refund_dest).unwrap(); + let refund_txs = db_tx.get_address_transactions(refund_address.as_str()).await.unwrap(); + assert!( + refund_txs.contains(&tx_fund_id), + "Refund address should track the transaction" + ); + + let utxos = db_tx.get_address_available_utxos(spend_address.as_str()).await.unwrap(); + assert_eq!(utxos.len(), 2); + assert!(utxos.iter().map(|(outpoint, _)| outpoint).any( + |outpoint| *outpoint == UtxoOutPoint::new(OutPointSourceId::Transaction(tx_fund_id), 0) + )); + assert!(utxos.iter().map(|(outpoint, _)| outpoint).any( + |outpoint| *outpoint == UtxoOutPoint::new(OutPointSourceId::Transaction(tx_fund_id), 1) + )); + let utxos = db_tx.get_address_available_utxos(refund_address.as_str()).await.unwrap(); + assert_eq!(utxos.len(), 2); + assert!(utxos.iter().map(|(outpoint, _)| outpoint).any( + |outpoint| *outpoint == UtxoOutPoint::new(OutPointSourceId::Transaction(tx_fund_id), 0) + )); + assert!(utxos.iter().map(|(outpoint, _)| outpoint).any( + |outpoint| *outpoint == UtxoOutPoint::new(OutPointSourceId::Transaction(tx_fund_id), 1) + )); + drop(db_tx); + + // ------------------------------------------------------------------------ + // Block 2: Spend HTLC 1 (Using Secret) + // ------------------------------------------------------------------------ + let input_htlc1 = TxInput::from_utxo(OutPointSourceId::Transaction(tx_fund_id), 0); + + let tx_spend_unsigned = Transaction::new( + 0, + vec![input_htlc1.clone()], + vec![TxOutput::Transfer( + OutputValue::Coin(Amount::from_atoms(900)), + Destination::AnyoneCanSpend, + )], + ) + .unwrap(); + + // Construct Spend Witness + // 1. Sign the tx + let utxo1 = local_state + .storage() + .transaction_ro() + .await + .unwrap() + .get_utxo(UtxoOutPoint::new( + OutPointSourceId::Transaction(tx_fund_id), + 0, + )) + .await + .unwrap() + .unwrap(); + + let sighash = signature_hash( + SigHashType::try_from(SigHashType::ALL).unwrap(), + &tx_spend_unsigned, + &[SighashInputCommitment::Utxo(Cow::Owned(utxo1.output().clone()))], + 0, + ) + .unwrap(); + let sig = sign_public_key_spending(&spend_sk, &spend_pk, &sighash, &mut rng).unwrap(); + + let auth_spend = AuthorizedHashedTimelockContractSpend::Spend(secret, sig.encode()); + let witness = InputWitness::Standard(StandardInputSignature::new( + SigHashType::try_from(SigHashType::ALL).unwrap(), + auth_spend.encode(), + )); + + let tx_spend = SignedTransaction::new(tx_spend_unsigned, vec![witness]).unwrap(); + let tx_spend_id = tx_spend.transaction().get_id(); + + tf.progress_time_seconds_since_epoch(target_block_time.as_secs()); + let best_block_id = tf.best_block_id(); + let block2 = tf + .make_block_builder() + .with_parent(best_block_id) + .with_transactions(vec![tx_spend.clone()]) + .build(&mut rng); + + tf.process_block(block2.clone(), BlockSource::Local).unwrap(); + local_state.scan_blocks(BlockHeight::new(2), vec![block2]).await.unwrap(); + + // ------------------------------------------------------------------------ + // Block 3 & 4: Refund HTLC 2 (Using Timeout) + // ------------------------------------------------------------------------ + // Refund requires blocks to pass. Timeout is 1 block count. + // Input created at Block 1. + tf.progress_time_seconds_since_epoch(target_block_time.as_secs()); + let best_block_id = tf.best_block_id(); + let block3 = tf.make_block_builder().with_parent(best_block_id).build(&mut rng); + tf.process_block(block3.clone(), BlockSource::Local).unwrap(); + local_state.scan_blocks(BlockHeight::new(3), vec![block3]).await.unwrap(); + + // Now construct Refund Tx + let input_htlc2 = TxInput::from_utxo(OutPointSourceId::Transaction(tx_fund_id), 1); + + let tx_refund_unsigned = Transaction::new( + 0, + vec![input_htlc2], + vec![TxOutput::Transfer( + OutputValue::Coin(Amount::from_atoms(900)), + Destination::AnyoneCanSpend, + )], + ) + .unwrap(); + + let utxo2 = local_state + .storage() + .transaction_ro() + .await + .unwrap() + .get_utxo(UtxoOutPoint::new( + OutPointSourceId::Transaction(tx_fund_id), + 1, + )) + .await + .unwrap() + .unwrap(); + + let sighash = signature_hash( + SigHashType::try_from(SigHashType::ALL).unwrap(), + &tx_refund_unsigned, + &[SighashInputCommitment::Utxo(Cow::Owned(utxo2.output().clone()))], + 0, + ) + .unwrap(); + let sig = sign_public_key_spending(&refund_sk, &refund_pk, &sighash, &mut rng).unwrap(); + + let auth_refund = AuthorizedHashedTimelockContractSpend::Refund(sig.encode()); + let witness = InputWitness::Standard(StandardInputSignature::new( + SigHashType::try_from(SigHashType::ALL).unwrap(), + auth_refund.encode(), + )); + + let tx_refund = SignedTransaction::new(tx_refund_unsigned, vec![witness]).unwrap(); + let tx_refund_id = tx_refund.transaction().get_id(); + + tf.progress_time_seconds_since_epoch(target_block_time.as_secs()); + let best_block_id = tf.best_block_id(); + let block4 = tf + .make_block_builder() + .with_parent(best_block_id) + .with_transactions(vec![tx_refund.clone()]) + .build(&mut rng); + + tf.process_block(block4.clone(), BlockSource::Local).unwrap(); + local_state.scan_blocks(BlockHeight::new(4), vec![block4]).await.unwrap(); + + // ------------------------------------------------------------------------ + // Verify Storage + // ------------------------------------------------------------------------ + let db_tx = local_state.storage().transaction_ro().await.unwrap(); + + // A. Check Spend Address Transactions + // Should see Fund Tx (because it's the spend authority in the outputs) + // Should see Spend Tx (because it spent the input using the key) + let spend_txs = db_tx.get_address_transactions(spend_address.as_str()).await.unwrap(); + assert!( + spend_txs.contains(&tx_fund_id), + "Spend address missing funding tx" + ); + assert!( + spend_txs.contains(&tx_spend_id), + "Spend address missing spend tx" + ); + // Should NOT contain refund tx + assert!( + !spend_txs.contains(&tx_refund_id), + "Spend address has refund tx" + ); + + // B. Check Refund Address Transactions + // Should see Fund Tx (because it's the refund authority in the outputs) + // Should see Refund Tx (because it refunded the input using the key) + let refund_txs = db_tx.get_address_transactions(refund_address.as_str()).await.unwrap(); + assert!( + refund_txs.contains(&tx_fund_id), + "Refund address missing funding tx" + ); + assert!( + refund_txs.contains(&tx_refund_id), + "Refund address missing refund tx" + ); + // Should NOT contain spend tx + assert!( + !refund_txs.contains(&tx_spend_id), + "Refund address has spend tx" + ); + + let utxos = db_tx.get_address_available_utxos(spend_address.as_str()).await.unwrap(); + assert!(utxos.is_empty()); + let utxos = db_tx.get_address_available_utxos(refund_address.as_str()).await.unwrap(); + assert!(utxos.is_empty()); +} + +fn create_command_tx( + nonce: AccountNonce, + command: AccountCommand, + last_tx_id: Id, + coins_amount: Amount, +) -> SignedTransaction { + TransactionBuilder::new() + .add_input( + TxInput::from_utxo(OutPointSourceId::Transaction(last_tx_id), 0), + InputWitness::NoSignature(None), + ) + .add_input( + TxInput::AccountCommand(nonce, command), + InputWitness::NoSignature(None), + ) + .add_output(TxOutput::Transfer( + OutputValue::Coin(coins_amount), + Destination::AnyoneCanSpend, + )) + .build() +} diff --git a/api-server/stack-test-suite/tests/v2/mod.rs b/api-server/stack-test-suite/tests/v2/mod.rs index 532eb9085..292762c49 100644 --- a/api-server/stack-test-suite/tests/v2/mod.rs +++ b/api-server/stack-test-suite/tests/v2/mod.rs @@ -36,6 +36,7 @@ mod statistics; mod token; mod token_ids; mod token_ticker; +mod token_transactions; mod transaction; mod transaction_merkle_path; mod transaction_output; diff --git a/api-server/stack-test-suite/tests/v2/token_transactions.rs b/api-server/stack-test-suite/tests/v2/token_transactions.rs new file mode 100644 index 000000000..37f7f5768 --- /dev/null +++ b/api-server/stack-test-suite/tests/v2/token_transactions.rs @@ -0,0 +1,386 @@ +// Copyright (c) 2025 RBB S.r.l +// opensource@mintlayer.org +// SPDX-License-Identifier: MIT +// Licensed under the MIT License; +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://github.com/mintlayer/mintlayer-core/blob/master/LICENSE +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use serde_json::Value; + +use chainstate_test_framework::empty_witness; +use common::{ + chain::{ + make_token_id, + tokens::{TokenId, TokenIssuance, TokenTotalSupply}, + AccountCommand, AccountNonce, UtxoOutPoint, + }, + primitives::H256, +}; + +use super::*; + +#[tokio::test] +async fn invalid_token_id() { + let (task, response) = spawn_webserver("/api/v2/token/invalid-token-id/transactions").await; + + assert_eq!(response.status(), 400); + + let body = response.text().await.unwrap(); + let body: serde_json::Value = serde_json::from_str(&body).unwrap(); + + assert_eq!(body["error"].as_str().unwrap(), "Invalid token Id"); + + task.abort(); +} + +#[tokio::test] +async fn invalid_offset() { + let (task, response) = spawn_webserver("/api/v2/transaction?offset=asd").await; + + assert_eq!(response.status(), 400); + + let body = response.text().await.unwrap(); + let body: serde_json::Value = serde_json::from_str(&body).unwrap(); + + assert_eq!(body["error"].as_str().unwrap(), "Invalid offset"); + + task.abort(); +} + +#[tokio::test] +async fn invalid_num_items() { + let token_id = TokenId::new(H256::zero()); + let chain_config = create_unit_test_config(); + let token_id = Address::new(&chain_config, token_id).expect("no error").into_string(); + + let (task, response) = + spawn_webserver(&format!("/api/v2/token/{token_id}/transactions?items=asd")).await; + + assert_eq!(response.status(), 400); + + let body = response.text().await.unwrap(); + let body: serde_json::Value = serde_json::from_str(&body).unwrap(); + + assert_eq!(body["error"].as_str().unwrap(), "Invalid number of items"); + + task.abort(); +} + +#[rstest] +#[trace] +#[case(Seed::from_entropy())] +#[tokio::test] +async fn invalid_num_items_max(#[case] seed: Seed) { + let mut rng = make_seedable_rng(seed); + let more_than_max = rng.gen_range(101..1000); + + let token_id = TokenId::new(H256::zero()); + let chain_config = create_unit_test_config(); + let token_id = Address::new(&chain_config, token_id).expect("no error").into_string(); + + let (task, response) = spawn_webserver(&format!( + "/api/v2/token/{token_id}/transactions?items={more_than_max}" + )) + .await; + + assert_eq!(response.status(), 400); + + let body = response.text().await.unwrap(); + let body: serde_json::Value = serde_json::from_str(&body).unwrap(); + + assert_eq!(body["error"].as_str().unwrap(), "Invalid number of items"); + + task.abort(); +} + +#[rstest] +#[trace] +#[case(Seed::from_entropy())] +#[tokio::test] +async fn ok(#[case] seed: Seed) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + let (tx, rx) = tokio::sync::oneshot::channel(); + + let task = tokio::spawn(async move { + let web_server_state = { + let mut rng = make_seedable_rng(seed); + let chain_config = create_unit_test_config(); + + let chainstate_blocks = { + let mut tf = TestFramework::builder(&mut rng) + .with_chain_config(chain_config.clone()) + .build(); + + let token_issuance_fee = + tf.chainstate.get_chain_config().fungible_token_issuance_fee(); + + let issuance = test_utils::token_utils::random_token_issuance_v1( + tf.chain_config(), + Destination::AnyoneCanSpend, + &mut rng, + ); + let amount_to_mint = match issuance.total_supply { + TokenTotalSupply::Fixed(limit) => { + Amount::from_atoms(rng.gen_range(1..=limit.into_atoms())) + } + TokenTotalSupply::Lockable | TokenTotalSupply::Unlimited => { + Amount::from_atoms(rng.gen_range(100..1000)) + } + }; + + let genesis_outpoint = UtxoOutPoint::new(tf.best_block_id().into(), 0); + let genesis_coins = chainstate_test_framework::get_output_value( + tf.chainstate.utxo(&genesis_outpoint).unwrap().unwrap().output(), + ) + .unwrap() + .coin_amount() + .unwrap(); + let coins_after_issue = (genesis_coins - token_issuance_fee).unwrap(); + + // Issue token + let issue_token_tx = TransactionBuilder::new() + .add_input(genesis_outpoint.into(), empty_witness(&mut rng)) + .add_output(TxOutput::Transfer( + OutputValue::Coin(coins_after_issue), + Destination::AnyoneCanSpend, + )) + .add_output(TxOutput::IssueFungibleToken(Box::new(TokenIssuance::V1( + issuance, + )))) + .build(); + let token_id = make_token_id( + &chain_config, + BlockHeight::new(1), + issue_token_tx.transaction().inputs(), + ) + .unwrap(); + let issue_token_tx_id = issue_token_tx.transaction().get_id(); + let block1 = + tf.make_block_builder().add_transaction(issue_token_tx).build(&mut rng); + + tf.process_block(block1.clone(), chainstate::BlockSource::Local).unwrap(); + + // Mint tokens + let token_supply_change_fee = + tf.chainstate.get_chain_config().token_supply_change_fee(BlockHeight::zero()); + let coins_after_mint = (coins_after_issue - token_supply_change_fee).unwrap(); + + let mint_tokens_tx = TransactionBuilder::new() + .add_input( + TxInput::from_command( + AccountNonce::new(0), + AccountCommand::MintTokens(token_id, amount_to_mint), + ), + empty_witness(&mut rng), + ) + .add_input( + TxInput::from_utxo(issue_token_tx_id.into(), 0), + empty_witness(&mut rng), + ) + .add_output(TxOutput::Transfer( + OutputValue::Coin(coins_after_mint), + Destination::AnyoneCanSpend, + )) + .add_output(TxOutput::Transfer( + OutputValue::TokenV1(token_id, amount_to_mint), + Destination::AnyoneCanSpend, + )) + .build(); + + let mint_tokens_tx_id = mint_tokens_tx.transaction().get_id(); + + let block2 = + tf.make_block_builder().add_transaction(mint_tokens_tx).build(&mut rng); + + tf.process_block(block2.clone(), chainstate::BlockSource::Local).unwrap(); + + // Unmint tokens + let coins_after_unmint = (coins_after_mint - token_supply_change_fee).unwrap(); + let tokens_to_unmint = Amount::from_atoms(1); + let tokens_leff_after_unmint = (amount_to_mint - tokens_to_unmint).unwrap(); + let unmint_tokens_tx = TransactionBuilder::new() + .add_input( + TxInput::from_command( + AccountNonce::new(1), + AccountCommand::UnmintTokens(token_id), + ), + empty_witness(&mut rng), + ) + .add_input( + TxInput::from_utxo(mint_tokens_tx_id.into(), 0), + empty_witness(&mut rng), + ) + .add_input( + TxInput::from_utxo(mint_tokens_tx_id.into(), 1), + empty_witness(&mut rng), + ) + .add_output(TxOutput::Transfer( + OutputValue::Coin(coins_after_unmint), + Destination::AnyoneCanSpend, + )) + .add_output(TxOutput::Transfer( + OutputValue::TokenV1(token_id, tokens_leff_after_unmint), + Destination::AnyoneCanSpend, + )) + .add_output(TxOutput::Burn(OutputValue::TokenV1( + token_id, + tokens_to_unmint, + ))) + .build(); + let unmint_tokens_tx_id = unmint_tokens_tx.transaction().get_id(); + + let block3 = + tf.make_block_builder().add_transaction(unmint_tokens_tx).build(&mut rng); + + tf.process_block(block3.clone(), chainstate::BlockSource::Local).unwrap(); + + // Change token metadata uri + let coins_after_change_token_authority = + (coins_after_unmint - token_supply_change_fee).unwrap(); + let change_token_authority_tx = TransactionBuilder::new() + .add_input( + TxInput::from_command( + AccountNonce::new(2), + AccountCommand::ChangeTokenMetadataUri( + token_id, + "http://uri".as_bytes().to_vec(), + ), + ), + empty_witness(&mut rng), + ) + .add_input( + TxInput::from_utxo(unmint_tokens_tx_id.into(), 0), + empty_witness(&mut rng), + ) + .add_output(TxOutput::Transfer( + OutputValue::Coin(coins_after_change_token_authority), + Destination::AnyoneCanSpend, + )) + .build(); + let change_token_authority_tx_id = change_token_authority_tx.transaction().get_id(); + + let block4 = tf + .make_block_builder() + .add_transaction(change_token_authority_tx) + .build(&mut rng); + + tf.process_block(block4.clone(), chainstate::BlockSource::Local).unwrap(); + + let token_transactions = [ + issue_token_tx_id, + mint_tokens_tx_id, + unmint_tokens_tx_id, + change_token_authority_tx_id, + ]; + + _ = tx.send(( + Address::new(&chain_config, token_id).expect("no error").into_string(), + token_transactions, + )); + + vec![block1, block2, block3, block4] + }; + + let storage = { + let mut storage = TransactionalApiServerInMemoryStorage::new(&chain_config); + + let mut db_tx = storage.transaction_rw().await.unwrap(); + db_tx.reinitialize_storage(&chain_config).await.unwrap(); + db_tx.commit().await.unwrap(); + + storage + }; + + let chain_config = Arc::new(chain_config); + let mut local_node = BlockchainState::new(Arc::clone(&chain_config), storage); + local_node.scan_genesis(chain_config.genesis_block()).await.unwrap(); + local_node.scan_blocks(BlockHeight::new(0), chainstate_blocks).await.unwrap(); + + ApiServerWebServerState { + db: Arc::new(local_node.storage().clone_storage().await), + chain_config: Arc::clone(&chain_config), + rpc: Arc::new(DummyRPC {}), + cached_values: Arc::new(CachedValues { + feerate_points: RwLock::new((get_time(), vec![])), + }), + time_getter: Default::default(), + } + }; + + web_server(listener, web_server_state, true).await + }); + + let (token_id, expected_transactions) = rx.await.unwrap(); + let num_tx = expected_transactions.len(); + + let url = format!("/api/v2/token/{token_id}/transactions?offset=999&items={num_tx}"); + + let response = reqwest::get(format!("http://{}:{}{url}", addr.ip(), addr.port())) + .await + .unwrap(); + + assert_eq!(response.status(), 200); + + let body = response.text().await.unwrap(); + eprintln!("body: {}", body); + let body: serde_json::Value = serde_json::from_str(&body).unwrap(); + let arr_body = body.as_array().unwrap(); + + assert_eq!(arr_body.len(), num_tx); + eprintln!("{expected_transactions:?}"); + for (tx_id, body) in expected_transactions.iter().rev().zip(arr_body) { + compare_body( + body, + &json!({ + "tx_id": tx_id, + }), + ); + } + + let mut rng = make_seedable_rng(seed); + let offset = rng.gen_range(1..num_tx); + let items = num_tx - offset; + + let tx_global_index = &arr_body[offset - 1].get("tx_global_index").unwrap(); + eprintln!("tx_global_index: '{tx_global_index}'"); + let url = + format!("/api/v2/token/{token_id}/transactions?offset={tx_global_index}&items={items}"); + + let response = reqwest::get(format!("http://{}:{}{url}", addr.ip(), addr.port())) + .await + .unwrap(); + + assert_eq!(response.status(), 200); + + let body = response.text().await.unwrap(); + eprintln!("body: {}", body); + let body: serde_json::Value = serde_json::from_str(&body).unwrap(); + let arr_body = body.as_array().unwrap(); + + assert_eq!(arr_body.len(), num_tx - offset); + for (tx_id, body) in expected_transactions.iter().rev().skip(offset).zip(arr_body) { + compare_body( + body, + &json!({ + "tx_id": tx_id, + }), + ); + } + + task.abort(); +} + +#[track_caller] +fn compare_body(body: &Value, expected_transaction: &Value) { + assert_eq!(body.get("tx_id").unwrap(), &expected_transaction["tx_id"]); +} diff --git a/api-server/storage-test-suite/src/basic.rs b/api-server/storage-test-suite/src/basic.rs index a3ba67ff0..e9b02fd77 100644 --- a/api-server/storage-test-suite/src/basic.rs +++ b/api-server/storage-test-suite/src/basic.rs @@ -27,8 +27,8 @@ use api_server_common::storage::{ block_aux_data::{BlockAuxData, BlockWithExtraData}, ApiServerStorage, ApiServerStorageError, ApiServerStorageRead, ApiServerStorageWrite, ApiServerTransactionRw, BlockInfo, CoinOrTokenStatistic, Delegation, FungibleTokenData, - LockedUtxo, Order, PoolDataWithExtraInfo, TransactionInfo, Transactional, TxAdditionalInfo, - Utxo, UtxoLock, UtxoWithExtraInfo, + LockedUtxo, Order, PoolDataWithExtraInfo, TokenTransaction, TransactionInfo, Transactional, + TxAdditionalInfo, Utxo, UtxoLock, UtxoWithExtraInfo, }, }; use crypto::{ @@ -592,7 +592,7 @@ where .unwrap(); assert_eq!(txs.len() as u64, take_txs); for (i, tx) in txs.iter().enumerate() { - assert_eq!(tx.global_tx_index, expected_last_tx_global_index - i as u64); + assert_eq!(tx.tx_global_index, expected_last_tx_global_index - i as u64); } } @@ -675,7 +675,7 @@ where .set_locked_utxo_at_height( outpoint.clone(), locked_utxo, - bob_address.as_str(), + &[bob_address.as_str()], block_height, ) .await @@ -718,7 +718,7 @@ where .set_locked_utxo_at_height( outpoint.clone(), locked_utxo, - bob_address.as_str(), + &[bob_address.as_str()], block_height, ) .await @@ -788,7 +788,7 @@ where .set_locked_utxo_at_height( outpoint.clone(), locked_utxo, - bob_address.as_str(), + &[bob_address.as_str()], block_height, ) .await @@ -800,7 +800,7 @@ where .set_utxo_at_height( outpoint.clone(), utxo, - bob_address.as_str(), + &[bob_address.as_str()], block_height.next_height(), ) .await @@ -813,7 +813,7 @@ where .set_utxo_at_height( outpoint.clone(), spent_utxo, - bob_address.as_str(), + &[bob_address.as_str()], next_block_height, ) .await @@ -840,7 +840,7 @@ where .set_locked_utxo_at_height( locked_outpoint.clone(), locked_utxo, - bob_address.as_str(), + &[bob_address.as_str()], block_height, ) .await @@ -859,7 +859,12 @@ where // set one and get it { db_tx - .set_utxo_at_height(outpoint.clone(), utxo, bob_address.as_str(), block_height) + .set_utxo_at_height( + outpoint.clone(), + utxo, + &[bob_address.as_str()], + block_height, + ) .await .unwrap(); @@ -892,7 +897,7 @@ where .set_utxo_at_height( outpoint2.clone(), utxo.clone(), - bob_address.as_str(), + &[bob_address.as_str()], block_height, ) .await @@ -917,7 +922,7 @@ where let utxo = Utxo::new(output2.clone(), None, Some(block_height)); expected_utxos.remove(&outpoint2); db_tx - .set_utxo_at_height(outpoint2, utxo, bob_address.as_str(), block_height) + .set_utxo_at_height(outpoint2, utxo, &[bob_address.as_str()], block_height) .await .unwrap(); @@ -933,6 +938,46 @@ where db_tx.commit().await.unwrap(); } + // Test token transactions + { + let mut db_tx = storage.transaction_rw().await.unwrap(); + let random_token_id = TokenId::new(H256::random_using(&mut rng)); + let token_transactions: Vec<_> = (0..10) + .map(|idx| { + let random_tx_id = Id::::new(H256::random_using(&mut rng)); + let block_height = BlockHeight::new(idx); + (idx, random_tx_id, block_height) + }) + .collect(); + + for (idx, tx_id, block_height) in &token_transactions { + db_tx + .set_token_transaction_at_height(random_token_id, *tx_id, *block_height, *idx) + .await + .unwrap(); + } + + let len = rng.gen_range(0..5); + let global_idx = rng.gen_range(5..=10); + let token_txs = + db_tx.get_token_transactions(random_token_id, len, global_idx).await.unwrap(); + eprintln!("getting len: {len} < idx {global_idx}"); + let expected_token_txs: Vec<_> = token_transactions + .iter() + .rev() + .filter_map(|(idx, tx_id, _)| { + let idx = *idx; + ((idx) < global_idx).then_some(TokenTransaction { + tx_global_index: idx, + tx_id: *tx_id, + }) + }) + .take(len as usize) + .collect(); + + assert_eq!(token_txs, expected_token_txs); + } + // Test setting/getting pool data { let mut db_tx = storage.transaction_rw().await.unwrap(); diff --git a/api-server/web-server/src/api/v2.rs b/api-server/web-server/src/api/v2.rs index 3d813139e..a17d6b1fc 100644 --- a/api-server/web-server/src/api/v2.rs +++ b/api-server/web-server/src/api/v2.rs @@ -126,6 +126,7 @@ pub fn routes< let router = router .route("/token", get(token_ids)) .route("/token/:id", get(token)) + .route("/token/:id/transactions", get(token_transactions)) .route("/token/ticker/:ticker", get(token_ids_by_ticker)) .route("/nft/:id", get(nft)); @@ -509,7 +510,7 @@ pub async fn transactions( &state.chain_config, tip_height, tx.block_aux, - tx.global_tx_index, + tx.tx_global_index, ) }) .collect(); @@ -1400,6 +1401,44 @@ pub async fn token_ids_by_ticker( Ok(Json(serde_json::Value::Array(token_ids))) } +pub async fn token_transactions( + Path(token_id): Path, + Query(params): Query>, + State(state): State, Arc>>, +) -> Result { + let token_id = Address::from_string(&state.chain_config, token_id) + .map_err(|_| { + ApiServerWebServerError::ClientError(ApiServerWebServerClientError::InvalidTokenId) + })? + .into_object(); + let offset_and_items = get_offset_and_items(¶ms)?; + + let db_tx = state.db.transaction_ro().await.map_err(|e| { + logging::log::error!("internal error: {e}"); + ApiServerWebServerError::ServerError(ApiServerWebServerServerError::InternalServerError) + })?; + + let txs = db_tx + .get_token_transactions(token_id, offset_and_items.items, offset_and_items.offset) + .await + .map_err(|e| { + logging::log::error!("internal error: {e}"); + ApiServerWebServerError::ServerError(ApiServerWebServerServerError::InternalServerError) + })?; + + let txs = txs + .into_iter() + .map(|tx| { + json!({ + "tx_global_index": tx.tx_global_index, + "tx_id": tx.tx_id, + }) + }) + .collect(); + + Ok(Json(serde_json::Value::Array(txs))) +} + async fn collect_currency_decimals_for_orders( db_tx: &impl ApiServerStorageRead, orders: impl Iterator,