From 32fb1b8d513220af7d46ab4c378d6ed6c78c9bba Mon Sep 17 00:00:00 2001 From: Boris Oncev Date: Sun, 16 Nov 2025 17:24:22 +0100 Subject: [PATCH 1/5] Add token transactions endpoint for the API Server --- .../src/storage/impls/in_memory/mod.rs | 69 +++- .../impls/in_memory/transactional/read.rs | 13 +- .../impls/in_memory/transactional/write.rs | 30 +- .../src/storage/impls/postgres/queries.rs | 102 ++++- .../impls/postgres/transactional/read.rs | 16 +- .../impls/postgres/transactional/write.rs | 39 +- .../src/storage/storage_api/mod.rs | 25 ++ api-server/scanner-lib/Cargo.toml | 6 +- .../scanner-lib/src/blockchain_state/mod.rs | 159 ++++++-- api-server/stack-test-suite/tests/v2/mod.rs | 1 + .../tests/v2/token_transactions.rs | 385 ++++++++++++++++++ api-server/storage-test-suite/src/basic.rs | 44 +- api-server/web-server/src/api/v2.rs | 43 ++ 13 files changed, 889 insertions(+), 43 deletions(-) create mode 100644 api-server/stack-test-suite/tests/v2/token_transactions.rs 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..8bee886a6 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 @@ -19,7 +19,7 @@ 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, + TokenTransaction, TransactionInfo, TransactionWithBlockInfo, Utxo, UtxoLock, UtxoWithExtraInfo, }; use common::{ address::Address, @@ -48,6 +48,7 @@ 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 +76,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 +175,27 @@ impl ApiServerInMemoryStorage { })) } + fn get_token_transactions( + &self, + token_id: TokenId, + len: u32, + global_tx_index: i64, + ) -> Result, ApiServerStorageError> { + Ok(self + .token_transactions_table + .get(&token_id) + .map_or_else(Vec::new, |transactions| { + transactions + .iter() + .rev() + .flat_map(|(_, txs)| txs.iter()) + .cloned() + .flat_map(|tx| (tx.global_tx_index < global_tx_index).then_some(tx)) + .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 { @@ -864,6 +887,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 +979,36 @@ impl ApiServerInMemoryStorage { Ok(()) } + fn set_token_transactions_at_height( + &mut self, + token_id: TokenId, + transaction_ids: BTreeSet>, + block_height: BlockHeight, + ) -> Result<(), ApiServerStorageError> { + let next_tx_idx = self.token_transactions_table.get(&token_id).map_or(1, |tx| { + tx.values() + .last() + .expect("not empty") + .last() + .expect("not empty") + .global_tx_index + + 1 + }); + self.token_transactions_table.entry(token_id).or_default().insert( + block_height, + transaction_ids + .into_iter() + .enumerate() + .map(|(idx, tx_id)| TokenTransaction { + global_tx_index: next_tx_idx + idx as i64, + tx_id, + }) + .collect(), + ); + + Ok(()) + } + fn set_mainchain_block( &mut self, block_id: Id, 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..701b03f5b 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, + global_tx_index: i64, + ) -> Result, ApiServerStorageError> { + self.transaction.get_token_transactions(token_id, len, global_tx_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..f2444b398 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,16 @@ impl ApiServerStorageWrite for ApiServerInMemoryStorageTransactionalRw<'_> { .set_address_transactions_at_height(address, transactions, block_height) } + async fn set_token_transactions_at_height( + &mut self, + token_id: TokenId, + transactions: BTreeSet>, + block_height: BlockHeight, + ) -> Result<(), ApiServerStorageError> { + self.transaction + .set_token_transactions_at_height(token_id, transactions, block_height) + } + async fn set_mainchain_block( &mut self, block_id: Id, @@ -331,6 +348,15 @@ impl ApiServerStorageRead for ApiServerInMemoryStorageTransactionalRw<'_> { self.transaction.get_address_transactions(address) } + async fn get_token_transactions( + &self, + token_id: TokenId, + len: u32, + global_tx_index: i64, + ) -> Result, ApiServerStorageError> { + self.transaction.get_token_transactions(token_id, len, global_tx_index) + } + async fn get_latest_blocktimestamps( &self, ) -> Result, ApiServerStorageError> { 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..78462a52b 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, }, }; @@ -495,6 +495,49 @@ impl<'a, 'b> QueryFromConnection<'a, 'b> { Ok(transaction_ids) } + pub async fn get_token_transactions( + &self, + token_id: TokenId, + len: u32, + global_tx_index: i64, + ) -> Result, ApiServerStorageError> { + let len = len as i64; + let rows = self + .tx + .query( + r#" + SELECT global_tx_index, transaction_id + FROM ml.token_transactions + WHERE token_id = $1 AND global_tx_index < $2 + ORDER BY global_tx_index DESC + LIMIT $3; + "#, + &[&token_id.encode(), &global_tx_index, &len], + ) + .await + .map_err(|e| ApiServerStorageError::LowLevelStorageError(e.to_string()))?; + + let mut transactions = Vec::with_capacity(rows.len()); + + for row in &rows { + let global_tx_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 { + global_tx_index, + tx_id, + }); + } + + Ok(transactions) + } + pub async fn del_address_transactions_above_height( &mut self, block_height: BlockHeight, @@ -512,6 +555,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 +598,32 @@ impl<'a, 'b> QueryFromConnection<'a, 'b> { Ok(()) } + pub async fn set_token_transactions_at_height( + &mut self, + token_id: TokenId, + transaction_ids: BTreeSet>, + block_height: BlockHeight, + ) -> Result<(), ApiServerStorageError> { + let height = Self::block_height_to_postgres_friendly(block_height); + + for transaction_id in transaction_ids { + self.tx + .execute( + r#" + INSERT INTO ml.token_transactions (token_id, block_height, transaction_id) + VALUES ($1, $2, $3) + ON CONFLICT (token_id, block_height, transaction_id) + DO NOTHING; + "#, + &[&token_id.encode(), &height, &transaction_id.encode()], + ) + .await + .map_err(|e| ApiServerStorageError::LowLevelStorageError(e.to_string()))?; + } + + Ok(()) + } + pub async fn get_latest_blocktimestamps( &self, ) -> Result, ApiServerStorageError> { @@ -732,6 +818,20 @@ impl<'a, 'b> QueryFromConnection<'a, 'b> { ) .await?; + self.just_execute( + "CREATE TABLE ml.token_transactions ( + global_tx_index bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + token_id bytea NOT NULL, + block_height bigint NOT NULL, + transaction_id bytea NOT NULL, + UNIQUE (token_id, block_height, transaction_id) + );", + ) + .await?; + + self.just_execute("CREATE INDEX token_transactions_global_tx_index ON ml.token_transactions (token_id, global_tx_index DESC);") + .await?; + self.just_execute( "CREATE TABLE ml.utxo ( outpoint bytea NOT NULL, 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..9d613f1f5 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, + global_tx_index: i64, + ) -> Result, ApiServerStorageError> { + let conn = QueryFromConnection::new(self.connection.as_ref().expect(CONN_ERR)); + let res = conn.get_token_transactions(token_id, len, global_tx_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..0a9b5645f 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,19 @@ impl ApiServerStorageWrite for ApiServerPostgresTransactionalRw<'_> { Ok(()) } + async fn set_token_transactions_at_height( + &mut self, + token_id: TokenId, + transaction_ids: BTreeSet>, + block_height: BlockHeight, + ) -> Result<(), ApiServerStorageError> { + let mut conn = QueryFromConnection::new(self.connection.as_ref().expect(CONN_ERR)); + conn.set_token_transactions_at_height(token_id, transaction_ids, block_height) + .await?; + + Ok(()) + } + async fn set_mainchain_block( &mut self, block_id: Id, @@ -434,6 +457,18 @@ impl ApiServerStorageRead for ApiServerPostgresTransactionalRw<'_> { Ok(res) } + async fn get_token_transactions( + &self, + token_id: TokenId, + len: u32, + global_tx_index: i64, + ) -> Result, ApiServerStorageError> { + let conn = QueryFromConnection::new(self.connection.as_ref().expect(CONN_ERR)); + let res = conn.get_token_transactions(token_id, len, global_tx_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..4722fffb9 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 @@ -581,6 +581,12 @@ pub struct AmountWithDecimals { pub decimals: u8, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TokenTransaction { + pub global_tx_index: i64, + pub tx_id: Id, +} + #[async_trait::async_trait] pub trait ApiServerStorageRead: Sync { async fn is_initialized(&self) -> Result; @@ -609,6 +615,13 @@ pub trait ApiServerStorageRead: Sync { address: &str, ) -> Result>, ApiServerStorageError>; + async fn get_token_transactions( + &self, + token_id: TokenId, + len: u32, + global_tx_index: i64, + ) -> Result, ApiServerStorageError>; + async fn get_best_block(&self) -> Result; async fn get_latest_blocktimestamps( @@ -806,6 +819,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 +847,13 @@ pub trait ApiServerStorageWrite: ApiServerStorageRead { block_height: BlockHeight, ) -> Result<(), ApiServerStorageError>; + async fn set_token_transactions_at_height( + &mut self, + token_id: TokenId, + transaction_ids: BTreeSet>, + block_height: BlockHeight, + ) -> Result<(), ApiServerStorageError>; + async fn set_mainchain_block( &mut self, block_id: Id, 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..8625e3642 100644 --- a/api-server/scanner-lib/src/blockchain_state/mod.rs +++ b/api-server/scanner-lib/src/blockchain_state/mod.rs @@ -13,6 +13,12 @@ // See the License for the specific language governing permissions and // limitations under the License. +use std::{ + collections::{BTreeMap, BTreeSet}, + ops::{Add, Sub}, + sync::Arc, +}; + use crate::sync::local_state::LocalBlockchainState; use api_server_common::storage::storage_api::{ block_aux_data::{BlockAuxData, BlockWithExtraData}, @@ -31,6 +37,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 +49,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 tokens_accounting::TokensAccountingView; use tx_verifier::transaction_verifier::{ calculate_tokens_burned_in_outputs, distribute_pos_reward, }; +use futures::{stream::FuturesOrdered, TryStreamExt}; +use serialization::DecodeAll; + mod pos_adapter; #[derive(Debug, thiserror::Error)] @@ -331,6 +338,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?; @@ -1007,8 +1016,7 @@ async fn update_tables_from_transaction( Arc::clone(&chain_config), db_tx, block_height, - transaction.transaction().inputs(), - transaction.transaction(), + transaction, ) .await .expect("Unable to update tables from transaction inputs"); @@ -1032,13 +1040,31 @@ async fn update_tables_from_transaction_inputs( chain_config: Arc, db_tx: &mut T, block_height: BlockHeight, - inputs: &[TxInput], - tx: &Transaction, + tx: &SignedTransaction, ) -> Result<(), ApiServerStorageError> { + let sigs = tx.signatures(); + let tx = tx.transaction(); let mut address_transactions: BTreeMap, BTreeSet>> = BTreeMap::new(); + let mut token_transactions: BTreeMap>> = BTreeMap::new(); - for input in inputs { + let update_token_transactions_from_order = + |order: &Order, token_transactions: &mut BTreeMap<_, BTreeSet<_>>| { + match order.give_currency { + CoinOrTokenId::TokenId(token_id) => { + token_transactions.entry(token_id).or_default().insert(tx.get_id()); + } + CoinOrTokenId::Coin => {} + } + match order.ask_currency { + CoinOrTokenId::TokenId(token_id) => { + token_transactions.entry(token_id).or_default().insert(tx.get_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 +1098,7 @@ async fn update_tables_from_transaction_inputs( block_height, ) .await; + token_transactions.entry(*token_id).or_default().insert(tx.get_id()); } AccountCommand::UnmintTokens(token_id) => { let total_burned = @@ -1099,6 +1126,7 @@ async fn update_tables_from_transaction_inputs( block_height, ) .await; + token_transactions.entry(*token_id).or_default().insert(tx.get_id()); } AccountCommand::FreezeToken(token_id, is_unfreezable) => { let issuance = @@ -1123,6 +1151,7 @@ async fn update_tables_from_transaction_inputs( block_height, ) .await; + token_transactions.entry(*token_id).or_default().insert(tx.get_id()); } AccountCommand::UnfreezeToken(token_id) => { let issuance = @@ -1147,6 +1176,7 @@ async fn update_tables_from_transaction_inputs( block_height, ) .await; + token_transactions.entry(*token_id).or_default().insert(tx.get_id()); } AccountCommand::LockTokenSupply(token_id) => { let issuance = @@ -1171,6 +1201,7 @@ async fn update_tables_from_transaction_inputs( block_height, ) .await; + token_transactions.entry(*token_id).or_default().insert(tx.get_id()); } AccountCommand::ChangeTokenAuthority(token_id, destination) => { let issuance = @@ -1195,6 +1226,7 @@ async fn update_tables_from_transaction_inputs( block_height, ) .await; + token_transactions.entry(*token_id).or_default().insert(tx.get_id()); } AccountCommand::ChangeTokenMetadataUri(token_id, metadata_uri) => { let issuance = @@ -1219,6 +1251,7 @@ async fn update_tables_from_transaction_inputs( block_height, ) .await; + token_transactions.entry(*token_id).or_default().insert(tx.get_id()); } AccountCommand::FillOrder(order_id, fill_amount_in_ask_currency, _) => { let order = db_tx.get_order(*order_id).await?.expect("must exist"); @@ -1226,12 +1259,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_token_transactions_from_order(&order, &mut token_transactions); } 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_token_transactions_from_order(&order, &mut token_transactions); } }, TxInput::OrderAccountCommand(cmd) => match cmd { @@ -1241,12 +1278,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_token_transactions_from_order(&order, &mut token_transactions); } 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_token_transactions_from_order(&order, &mut token_transactions); } OrderAccountCommand::FreezeOrder(order_id) => { let order = db_tx.get_order(*order_id).await?.expect("must exist"); @@ -1414,16 +1453,44 @@ async fn update_tables_from_transaction_inputs( block_height, ) .await; + token_transactions.entry(token_id).or_default().insert(tx.get_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, _) => { + token_transactions + .entry(token_id) + .or_default() + .insert(tx.get_id()); + } + OutputValue::Coin(_) => {} + } } TxOutput::LockThenTransfer(output_value, destination, _) | TxOutput::Transfer(output_value, destination) => { @@ -1446,6 +1513,10 @@ async fn update_tables_from_transaction_inputs( block_height, ) .await; + token_transactions + .entry(token_id) + .or_default() + .insert(tx.get_id()); } OutputValue::Coin(amount) => { decrease_address_amount( @@ -1465,13 +1536,19 @@ 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( + "Unable to set address transactions".to_string(), + ) + })?; + } + for (token_id, transactions) in token_transactions { + db_tx + .set_token_transactions_at_height(token_id, transactions, block_height) .await .map_err(|_| { ApiServerStorageError::LowLevelStorageError( @@ -1494,6 +1571,7 @@ async fn update_tables_from_transaction_outputs( ) -> Result<(), ApiServerStorageError> { let mut address_transactions: BTreeMap, BTreeSet>> = BTreeMap::new(); + let mut token_transactions: BTreeMap>> = BTreeMap::new(); for (idx, output) in outputs.iter().enumerate() { let outpoint = UtxoOutPoint::new(OutPointSourceId::Transaction(transaction_id), idx as u32); @@ -1505,6 +1583,7 @@ async fn update_tables_from_transaction_outputs( continue; } OutputValue::TokenV1(token_id, amount) => { + token_transactions.entry(*token_id).or_default().insert(transaction_id); (CoinOrTokenId::TokenId(*token_id), amount) } }; @@ -1578,11 +1657,13 @@ async fn update_tables_from_transaction_outputs( block_height, ) .await; + token_transactions.entry(token_id).or_default().insert(transaction_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); + token_transactions.entry(*token_id).or_default().insert(transaction_id); db_tx .set_nft_token_issuance(*token_id, block_height, *issuance.clone(), destination) @@ -1751,6 +1832,7 @@ async fn update_tables_from_transaction_outputs( block_height, ) .await; + token_transactions.entry(*token_id).or_default().insert(transaction_id); Some(token_decimals(*token_id, &BTreeMap::new(), db_tx).await?.1) } OutputValue::Coin(amount) => { @@ -1817,6 +1899,7 @@ async fn update_tables_from_transaction_outputs( } OutputValue::TokenV0(_) => None, OutputValue::TokenV1(token_id, amount) => { + token_transactions.entry(*token_id).or_default().insert(transaction_id); if already_unlocked { increase_address_amount( db_tx, @@ -1860,10 +1943,18 @@ async fn update_tables_from_transaction_outputs( .expect("Unable to encode destination"); address_transactions.entry(address.clone()).or_default().insert(transaction_id); + { + let address = + Address::::new(&chain_config, htlc.refund_key.clone()) + .expect("Unable to encode destination"); + + address_transactions.entry(address.clone()).or_default().insert(transaction_id); + } let token_decimals = match output_value { OutputValue::Coin(_) | OutputValue::TokenV0(_) => None, OutputValue::TokenV1(token_id, _) => { + token_transactions.entry(*token_id).or_default().insert(transaction_id); Some(token_decimals(*token_id, &BTreeMap::new(), db_tx).await?.1) } }; @@ -1878,9 +1969,12 @@ async fn update_tables_from_transaction_outputs( } 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) => { + token_transactions.entry(*id).or_default().insert(transaction_id); + (CoinOrTokenId::TokenId(*id), *amount) + } OutputValue::TokenV0(_) => panic!("unsupported token"), }; @@ -1908,13 +2002,20 @@ 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( + "Unable to set address transactions".to_string(), + ) + })?; + } + + for (token_id, transactions) in token_transactions { + db_tx + .set_token_transactions_at_height(token_id, transactions, block_height) .await .map_err(|_| { ApiServerStorageError::LowLevelStorageError( 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..bbdd20739 --- /dev/null +++ b/api-server/stack-test-suite/tests/v2/token_transactions.rs @@ -0,0 +1,385 @@ +// 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); + 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..6538d2b1b 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::{ @@ -933,6 +933,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 (_, tx_id, block_height) in &token_transactions { + db_tx + .set_token_transactions_at_height(random_token_id, [*tx_id].into(), *block_height) + .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 as i64 + 1; + ((idx) < global_idx).then_some(TokenTransaction { + global_tx_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..0a8f4f037 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)); @@ -1400,6 +1401,48 @@ 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 as i64, + ) + .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.global_tx_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, From 1d8d3f8e70f125515b9bc638c5452f9ea9acd483 Mon Sep 17 00:00:00 2001 From: Boris Oncev Date: Mon, 24 Nov 2025 06:52:35 +0100 Subject: [PATCH 2/5] fix comments --- api-server/CHANGELOG.md | 1 + .../src/storage/impls/in_memory/mod.rs | 52 +- .../impls/in_memory/transactional/read.rs | 4 +- .../impls/in_memory/transactional/write.rs | 4 +- .../src/storage/impls/mod.rs | 2 +- .../src/storage/impls/postgres/queries.rs | 61 +- .../impls/postgres/transactional/read.rs | 4 +- .../impls/postgres/transactional/write.rs | 4 +- .../src/storage/storage_api/mod.rs | 26 +- .../scanner-lib/src/blockchain_state/mod.rs | 11 +- api-server/scanner-lib/src/sync/tests/mod.rs | 667 +++++++++++++++++- .../tests/v2/token_transactions.rs | 1 + api-server/storage-test-suite/src/basic.rs | 6 +- api-server/web-server/src/api/v2.rs | 10 +- 14 files changed, 771 insertions(+), 82 deletions(-) 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 8bee886a6..990db08e0 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 @@ -48,7 +48,7 @@ struct ApiServerInMemoryStorage { address_balance_table: BTreeMap>>, address_locked_balance_table: BTreeMap>, address_transactions_table: BTreeMap>>>, - token_transactions_table: BTreeMap>>, + token_transactions_table: BTreeMap>>, delegation_table: BTreeMap>, main_chain_blocks_table: BTreeMap>, pool_data_table: BTreeMap>, @@ -179,7 +179,7 @@ impl ApiServerInMemoryStorage { &self, token_id: TokenId, len: u32, - global_tx_index: i64, + tx_global_index: u64, ) -> Result, ApiServerStorageError> { Ok(self .token_transactions_table @@ -190,7 +190,7 @@ impl ApiServerInMemoryStorage { .rev() .flat_map(|(_, txs)| txs.iter()) .cloned() - .flat_map(|tx| (tx.global_tx_index < global_tx_index).then_some(tx)) + .flat_map(|tx| (tx.tx_global_index < tx_global_index).then_some(tx)) .take(len as usize) .collect() })) @@ -237,7 +237,7 @@ impl ApiServerInMemoryStorage { additional_info: additinal_data.clone(), }, block_aux: *block_aux, - global_tx_index: *tx_global_index, + tx_global_index: *tx_global_index, } }, ) @@ -263,7 +263,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()) @@ -985,26 +985,30 @@ impl ApiServerInMemoryStorage { transaction_ids: BTreeSet>, block_height: BlockHeight, ) -> Result<(), ApiServerStorageError> { - let next_tx_idx = self.token_transactions_table.get(&token_id).map_or(1, |tx| { - tx.values() - .last() - .expect("not empty") - .last() - .expect("not empty") - .global_tx_index - + 1 - }); - self.token_transactions_table.entry(token_id).or_default().insert( - block_height, - transaction_ids - .into_iter() - .enumerate() - .map(|(idx, tx_id)| TokenTransaction { - global_tx_index: next_tx_idx + idx as i64, + if transaction_ids.is_empty() { + return Ok(()); + } + + let next_tx_idx = self + .token_transactions_table + .values() + .flat_map(|by_height| by_height.values()) + .flat_map(|tx_set| tx_set.iter()) + .map(|tx| tx.tx_global_index + 1) + .max() + .unwrap_or(0); + + self.token_transactions_table + .entry(token_id) + .or_default() + .entry(block_height) + .or_default() + .extend( + transaction_ids.into_iter().enumerate().map(|(idx, tx_id)| TokenTransaction { + tx_global_index: next_tx_idx + idx as u64, tx_id, - }) - .collect(), - ); + }), + ); 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 701b03f5b..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 @@ -72,9 +72,9 @@ impl ApiServerStorageRead for ApiServerInMemoryStorageTransactionalRo<'_> { &self, token_id: TokenId, len: u32, - global_tx_index: i64, + tx_global_index: u64, ) -> Result, ApiServerStorageError> { - self.transaction.get_token_transactions(token_id, len, global_tx_index) + self.transaction.get_token_transactions(token_id, len, tx_global_index) } async fn get_block( 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 f2444b398..334048d01 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 @@ -352,9 +352,9 @@ impl ApiServerStorageRead for ApiServerInMemoryStorageTransactionalRw<'_> { &self, token_id: TokenId, len: u32, - global_tx_index: i64, + tx_global_index: u64, ) -> Result, ApiServerStorageError> { - self.transaction.get_token_transactions(token_id, len, global_tx_index) + self.transaction.get_token_transactions(token_id, len, tx_global_index) } async fn get_latest_blocktimestamps( 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 78462a52b..314de783a 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 @@ -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 { @@ -499,20 +501,21 @@ impl<'a, 'b> QueryFromConnection<'a, 'b> { &self, token_id: TokenId, len: u32, - global_tx_index: i64, + 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 global_tx_index, transaction_id + SELECT tx_global_index, transaction_id FROM ml.token_transactions - WHERE token_id = $1 AND global_tx_index < $2 - ORDER BY global_tx_index DESC + WHERE token_id = $1 AND tx_global_index < $2 + ORDER BY tx_global_index DESC LIMIT $3; "#, - &[&token_id.encode(), &global_tx_index, &len], + &[&token_id.encode(), &tx_global_index, &len], ) .await .map_err(|e| ApiServerStorageError::LowLevelStorageError(e.to_string()))?; @@ -520,7 +523,7 @@ impl<'a, 'b> QueryFromConnection<'a, 'b> { let mut transactions = Vec::with_capacity(rows.len()); for row in &rows { - let global_tx_index: i64 = row.get(0); + 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!( @@ -530,7 +533,7 @@ impl<'a, 'b> QueryFromConnection<'a, 'b> { })?; transactions.push(TokenTransaction { - global_tx_index, + tx_global_index: tx_global_index as u64, tx_id, }); } @@ -763,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 @@ -820,7 +823,7 @@ impl<'a, 'b> QueryFromConnection<'a, 'b> { self.just_execute( "CREATE TABLE ml.token_transactions ( - global_tx_index bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + tx_global_index bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY (START WITH 0 MINVALUE 0), token_id bytea NOT NULL, block_height bigint NOT NULL, transaction_id bytea NOT NULL, @@ -829,7 +832,7 @@ impl<'a, 'b> QueryFromConnection<'a, 'b> { ) .await?; - self.just_execute("CREATE INDEX token_transactions_global_tx_index ON ml.token_transactions (token_id, global_tx_index DESC);") + self.just_execute("CREATE INDEX token_transactions_global_tx_index ON ml.token_transactions (token_id, tx_global_index DESC);") .await?; self.just_execute( @@ -1903,12 +1906,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; "#, @@ -1921,7 +1924,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| { @@ -1941,7 +1944,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() @@ -1950,10 +1953,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( @@ -1961,13 +1964,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], @@ -1979,7 +1982,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| { @@ -1999,7 +2002,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() @@ -2013,7 +2016,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; "#, @@ -2030,7 +2033,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> { @@ -2039,13 +2042,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()))?; 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 9d613f1f5..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 @@ -98,10 +98,10 @@ impl ApiServerStorageRead for ApiServerPostgresTransactionalRo<'_> { &self, token_id: TokenId, len: u32, - global_tx_index: i64, + 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, global_tx_index).await?; + let res = conn.get_token_transactions(token_id, len, tx_global_index).await?; Ok(res) } 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 0a9b5645f..710e590ad 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 @@ -461,10 +461,10 @@ impl ApiServerStorageRead for ApiServerPostgresTransactionalRw<'_> { &self, token_id: TokenId, len: u32, - global_tx_index: i64, + 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, global_tx_index).await?; + let res = conn.get_token_transactions(token_id, len, tx_global_index).await?; Ok(res) } 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 4722fffb9..70183460d 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 @@ -14,6 +14,7 @@ // limitations under the License. use std::{ + cmp::Ordering, collections::{BTreeMap, BTreeSet}, fmt::Display, str::FromStr, @@ -68,6 +69,8 @@ pub enum ApiServerStorageError { AddressableError, #[error("Block timestamp too high: {0}")] TimestampTooHigh(BlockTimestamp), + #[error("Tx global index to hight: {0}")] + TxGlobalIndexTooHigh(u64), #[error("Id creation error: {0}")] IdCreationError(#[from] IdCreationError), } @@ -562,7 +565,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 { @@ -583,10 +586,22 @@ pub struct AmountWithDecimals { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct TokenTransaction { - pub global_tx_index: i64, + pub tx_global_index: u64, pub tx_id: Id, } +impl PartialOrd for TokenTransaction { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for TokenTransaction { + fn cmp(&self, other: &Self) -> Ordering { + self.tx_id.cmp(&other.tx_id) + } +} + #[async_trait::async_trait] pub trait ApiServerStorageRead: Sync { async fn is_initialized(&self) -> Result; @@ -615,11 +630,15 @@ pub trait ApiServerStorageRead: Sync { address: &str, ) -> Result>, ApiServerStorageError>; + /// Return a page of TX IDs that reference this token_id, with a limit of len and older + /// tx_global_index than the specified. + /// The tx_global_index is only ordered by block height and are not continuous for a specific + /// token_id. async fn get_token_transactions( &self, token_id: TokenId, len: u32, - global_tx_index: i64, + tx_global_index: u64, ) -> Result, ApiServerStorageError>; async fn get_best_block(&self) -> Result; @@ -847,6 +866,7 @@ pub trait ApiServerStorageWrite: ApiServerStorageRead { block_height: BlockHeight, ) -> Result<(), ApiServerStorageError>; + /// Append new token transactions with increasing tx_global_index at this block height async fn set_token_transactions_at_height( &mut self, token_id: TokenId, diff --git a/api-server/scanner-lib/src/blockchain_state/mod.rs b/api-server/scanner-lib/src/blockchain_state/mod.rs index 8625e3642..32749339e 100644 --- a/api-server/scanner-lib/src/blockchain_state/mod.rs +++ b/api-server/scanner-lib/src/blockchain_state/mod.rs @@ -19,7 +19,8 @@ use std::{ sync::Arc, }; -use crate::sync::local_state::LocalBlockchainState; +use futures::{stream::FuturesOrdered, TryStreamExt}; + use api_server_common::storage::storage_api::{ block_aux_data::{BlockAuxData, BlockWithExtraData}, ApiServerStorage, ApiServerStorageError, ApiServerStorageRead, ApiServerStorageWrite, @@ -51,13 +52,13 @@ use common::{ }; use orders_accounting::OrderData; use pos_accounting::{PoSAccountingView, PoolData}; +use serialization::DecodeAll; use tokens_accounting::TokensAccountingView; use tx_verifier::transaction_verifier::{ calculate_tokens_burned_in_outputs, distribute_pos_reward, }; -use futures::{stream::FuturesOrdered, TryStreamExt}; -use serialization::DecodeAll; +use crate::sync::local_state::LocalBlockchainState; mod pos_adapter; @@ -1552,7 +1553,7 @@ async fn update_tables_from_transaction_inputs( .await .map_err(|_| { ApiServerStorageError::LowLevelStorageError( - "Unable to set address transactions".to_string(), + "Unable to set token transactions".to_string(), ) })?; } @@ -2019,7 +2020,7 @@ async fn update_tables_from_transaction_outputs( .await .map_err(|_| { ApiServerStorageError::LowLevelStorageError( - "Unable to set address transactions".to_string(), + "Unable to set token transactions".to_string(), ) })?; } diff --git a/api-server/scanner-lib/src/sync/tests/mod.rs b/api-server/scanner-lib/src/sync/tests/mod.rs index 638c03bff..b1701d339 100644 --- a/api-server/scanner-lib/src/sync/tests/mod.rs +++ b/api-server/scanner-lib/src/sync/tests/mod.rs @@ -55,8 +55,8 @@ use common::{ }, stakelock::StakePoolData, timelock::OutputTimeLock, - CoinUnit, Destination, OrderId, OutPointSourceId, PoolId, SignedTransaction, TxInput, - TxOutput, UtxoOutPoint, + AccountCommand, AccountNonce, CoinUnit, Destination, OrderId, OutPointSourceId, PoolId, + SignedTransaction, Transaction, TxInput, TxOutput, UtxoOutPoint, }, primitives::{per_thousand::PerThousand, Amount, CoinOrTokenId, Idable, H256}, }; @@ -1257,3 +1257,666 @@ 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) { + use common::chain::{ + make_order_id, make_token_id, + tokens::{IsTokenUnfreezable, TokenIssuance}, + AccountCommand, AccountNonce, OrderAccountCommand, OrderData, + }; + + 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) { + use common::chain::{ + htlc::{HashedTimelockContract, HtlcSecret}, + signature::inputsig::authorize_hashed_timelock_contract_spend::AuthorizedHashedTimelockContractSpend, + }; + + 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) + )); + 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/token_transactions.rs b/api-server/stack-test-suite/tests/v2/token_transactions.rs index bbdd20739..37f7f5768 100644 --- a/api-server/stack-test-suite/tests/v2/token_transactions.rs +++ b/api-server/stack-test-suite/tests/v2/token_transactions.rs @@ -337,6 +337,7 @@ async fn ok(#[case] seed: Seed) { 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, diff --git a/api-server/storage-test-suite/src/basic.rs b/api-server/storage-test-suite/src/basic.rs index 6538d2b1b..d79d76771 100644 --- a/api-server/storage-test-suite/src/basic.rs +++ b/api-server/storage-test-suite/src/basic.rs @@ -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); } } @@ -961,9 +961,9 @@ where .iter() .rev() .filter_map(|(idx, tx_id, _)| { - let idx = *idx as i64 + 1; + let idx = *idx; ((idx) < global_idx).then_some(TokenTransaction { - global_tx_index: idx, + tx_global_index: idx, tx_id: *tx_id, }) }) diff --git a/api-server/web-server/src/api/v2.rs b/api-server/web-server/src/api/v2.rs index 0a8f4f037..a17d6b1fc 100644 --- a/api-server/web-server/src/api/v2.rs +++ b/api-server/web-server/src/api/v2.rs @@ -510,7 +510,7 @@ pub async fn transactions( &state.chain_config, tip_height, tx.block_aux, - tx.global_tx_index, + tx.tx_global_index, ) }) .collect(); @@ -1419,11 +1419,7 @@ pub async fn token_transactions( })?; let txs = db_tx - .get_token_transactions( - token_id, - offset_and_items.items, - offset_and_items.offset as i64, - ) + .get_token_transactions(token_id, offset_and_items.items, offset_and_items.offset) .await .map_err(|e| { logging::log::error!("internal error: {e}"); @@ -1434,7 +1430,7 @@ pub async fn token_transactions( .into_iter() .map(|tx| { json!({ - "tx_global_index": tx.global_tx_index, + "tx_global_index": tx.tx_global_index, "tx_id": tx.tx_id, }) }) From 858d7889ca4db33bc28ba3eb1ef3865b5af8eee5 Mon Sep 17 00:00:00 2001 From: Boris Oncev Date: Wed, 26 Nov 2025 18:27:33 +0100 Subject: [PATCH 3/5] Add HTLC's refund key to UTXOs addresses --- .../src/storage/impls/in_memory/mod.rs | 18 ++- .../impls/in_memory/transactional/write.rs | 8 +- .../src/storage/impls/postgres/queries.rs | 110 ++++++++++++++---- .../impls/postgres/transactional/write.rs | 8 +- .../src/storage/storage_api/mod.rs | 4 +- .../scanner-lib/src/blockchain_state/mod.rs | 48 +++++--- api-server/scanner-lib/src/sync/tests/mod.rs | 8 ++ api-server/storage-test-suite/src/basic.rs | 23 ++-- 8 files changed, 165 insertions(+), 62 deletions(-) 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 990db08e0..e6bcca525 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 @@ -1158,11 +1158,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(()) } @@ -1170,14 +1175,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/write.rs b/api-server/api-server-common/src/storage/impls/in_memory/transactional/write.rs index 334048d01..d08cd5c90 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 @@ -193,21 +193,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( 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 314de783a..6c86b6b18 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 @@ -840,7 +840,6 @@ impl<'a, 'b> QueryFromConnection<'a, 'b> { outpoint bytea NOT NULL, block_height bigint, spent BOOLEAN NOT NULL, - address TEXT NOT NULL, utxo bytea NOT NULL, PRIMARY KEY (outpoint, block_height) );", @@ -855,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, @@ -864,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), @@ -2096,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 @@ -2138,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], ) @@ -2223,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); @@ -2232,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(()) } @@ -2247,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); @@ -2258,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(()) } @@ -2279,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(()) } @@ -2296,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/write.rs b/api-server/api-server-common/src/storage/impls/postgres/transactional/write.rs index 710e590ad..b85bae51c 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 @@ -241,11 +241,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(()) } @@ -254,11 +254,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(()) } 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 70183460d..58f5bb6b8 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 @@ -928,7 +928,7 @@ pub trait ApiServerStorageWrite: ApiServerStorageRead { &mut self, outpoint: UtxoOutPoint, utxo: Utxo, - address: &str, + addresses: &[&str], block_height: BlockHeight, ) -> Result<(), ApiServerStorageError>; @@ -936,7 +936,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/src/blockchain_state/mod.rs b/api-server/scanner-lib/src/blockchain_state/mod.rs index 32749339e..c8048b109 100644 --- a/api-server/scanner-lib/src/blockchain_state/mod.rs +++ b/api-server/scanner-lib/src/blockchain_state/mod.rs @@ -307,7 +307,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?; } } @@ -1853,7 +1855,7 @@ async fn update_tables_from_transaction_outputs( UtxoOutPoint::new(OutPointSourceId::Transaction(transaction_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"); } @@ -1927,30 +1929,41 @@ 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(address.clone()).or_default().insert(transaction_id); - { - let address = - Address::::new(&chain_config, htlc.refund_key.clone()) - .expect("Unable to encode destination"); + address_transactions + .entry(spend_address.clone()) + .or_default() + .insert(transaction_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(transaction_id); let token_decimals = match output_value { OutputValue::Coin(_) | OutputValue::TokenV0(_) => None, @@ -1964,7 +1977,12 @@ async fn update_tables_from_transaction_outputs( UtxoOutPoint::new(OutPointSourceId::Transaction(transaction_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"); } @@ -2182,7 +2200,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 b1701d339..f54fb86e4 100644 --- a/api-server/scanner-lib/src/sync/tests/mod.rs +++ b/api-server/scanner-lib/src/sync/tests/mod.rs @@ -1725,6 +1725,14 @@ async fn htlc_addresses_storage_check(#[case] seed: Seed) { 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); // ------------------------------------------------------------------------ diff --git a/api-server/storage-test-suite/src/basic.rs b/api-server/storage-test-suite/src/basic.rs index d79d76771..0d9cb7fbe 100644 --- a/api-server/storage-test-suite/src/basic.rs +++ b/api-server/storage-test-suite/src/basic.rs @@ -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(); From ffc08c4ac3cf9b8f27d2c016f241f4e6f8fed44b Mon Sep 17 00:00:00 2001 From: Boris Oncev Date: Mon, 1 Dec 2025 08:16:34 +0100 Subject: [PATCH 4/5] fix tx_global_index consistency with transactions table --- .../src/storage/impls/in_memory/mod.rs | 75 +++++----- .../impls/in_memory/transactional/write.rs | 13 +- .../src/storage/impls/postgres/queries.rs | 34 ++--- .../impls/postgres/transactional/write.rs | 14 +- .../src/storage/storage_api/mod.rs | 31 ++-- .../scanner-lib/src/blockchain_state/mod.rs | 138 ++++++++---------- api-server/scanner-lib/src/sync/tests/mod.rs | 21 +-- api-server/storage-test-suite/src/basic.rs | 4 +- 8 files changed, 159 insertions(+), 171 deletions(-) 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 e6bcca525..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, - TokenTransaction, 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,7 +66,8 @@ struct ApiServerInMemoryStorage { address_balance_table: BTreeMap>>, address_locked_balance_table: BTreeMap>, address_transactions_table: BTreeMap>>>, - token_transactions_table: BTreeMap>>, + token_transactions_table: + BTreeMap>>, delegation_table: BTreeMap>, main_chain_blocks_table: BTreeMap>, pool_data_table: BTreeMap>, @@ -188,9 +207,9 @@ impl ApiServerInMemoryStorage { transactions .iter() .rev() - .flat_map(|(_, txs)| txs.iter()) - .cloned() + .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() })) @@ -979,36 +998,22 @@ impl ApiServerInMemoryStorage { Ok(()) } - fn set_token_transactions_at_height( + fn set_token_transaction_at_height( &mut self, token_id: TokenId, - transaction_ids: BTreeSet>, + tx_id: Id, block_height: BlockHeight, + tx_global_index: u64, ) -> Result<(), ApiServerStorageError> { - if transaction_ids.is_empty() { - return Ok(()); - } - - let next_tx_idx = self - .token_transactions_table - .values() - .flat_map(|by_height| by_height.values()) - .flat_map(|tx_set| tx_set.iter()) - .map(|tx| tx.tx_global_index + 1) - .max() - .unwrap_or(0); - self.token_transactions_table .entry(token_id) .or_default() .entry(block_height) .or_default() - .extend( - transaction_ids.into_iter().enumerate().map(|(idx, tx_id)| TokenTransaction { - tx_global_index: next_tx_idx + idx as u64, - tx_id, - }), - ); + .replace(TokenTransactionOrderedByTxId(TokenTransaction { + tx_global_index, + tx_id, + })); Ok(()) } 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 d08cd5c90..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 @@ -111,14 +111,19 @@ impl ApiServerStorageWrite for ApiServerInMemoryStorageTransactionalRw<'_> { .set_address_transactions_at_height(address, transactions, block_height) } - async fn set_token_transactions_at_height( + async fn set_token_transaction_at_height( &mut self, token_id: TokenId, - transactions: BTreeSet>, + tx_id: Id, block_height: BlockHeight, + tx_global_index: u64, ) -> Result<(), ApiServerStorageError> { - self.transaction - .set_token_transactions_at_height(token_id, transactions, block_height) + self.transaction.set_token_transaction_at_height( + token_id, + tx_id, + block_height, + tx_global_index, + ) } async fn set_mainchain_block( 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 6c86b6b18..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 @@ -604,25 +604,25 @@ impl<'a, 'b> QueryFromConnection<'a, 'b> { pub async fn set_token_transactions_at_height( &mut self, token_id: TokenId, - transaction_ids: BTreeSet>, + 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)?; - for transaction_id in transaction_ids { - self.tx - .execute( - r#" - INSERT INTO ml.token_transactions (token_id, block_height, transaction_id) - VALUES ($1, $2, $3) - ON CONFLICT (token_id, block_height, transaction_id) - DO NOTHING; - "#, - &[&token_id.encode(), &height, &transaction_id.encode()], - ) - .await - .map_err(|e| ApiServerStorageError::LowLevelStorageError(e.to_string()))?; - } + 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(()) } @@ -823,11 +823,11 @@ impl<'a, 'b> QueryFromConnection<'a, 'b> { self.just_execute( "CREATE TABLE ml.token_transactions ( - tx_global_index bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY (START WITH 0 MINVALUE 0), + tx_global_index bigint PRIMARY KEY, token_id bytea NOT NULL, block_height bigint NOT NULL, transaction_id bytea NOT NULL, - UNIQUE (token_id, block_height, transaction_id) + UNIQUE (token_id, transaction_id, block_height) );", ) .await?; 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 b85bae51c..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 @@ -131,15 +131,21 @@ impl ApiServerStorageWrite for ApiServerPostgresTransactionalRw<'_> { Ok(()) } - async fn set_token_transactions_at_height( + async fn set_token_transaction_at_height( &mut self, token_id: TokenId, - transaction_ids: BTreeSet>, + 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_ids, block_height) - .await?; + conn.set_token_transactions_at_height( + token_id, + transaction_id, + block_height, + tx_global_index, + ) + .await?; Ok(()) } 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 58f5bb6b8..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 @@ -14,7 +14,6 @@ // limitations under the License. use std::{ - cmp::Ordering, collections::{BTreeMap, BTreeSet}, fmt::Display, str::FromStr, @@ -69,7 +68,7 @@ pub enum ApiServerStorageError { AddressableError, #[error("Block timestamp too high: {0}")] TimestampTooHigh(BlockTimestamp), - #[error("Tx global index to hight: {0}")] + #[error("Tx global index too hight: {0}")] TxGlobalIndexTooHigh(u64), #[error("Id creation error: {0}")] IdCreationError(#[from] IdCreationError), @@ -590,18 +589,6 @@ pub struct TokenTransaction { pub tx_id: Id, } -impl PartialOrd for TokenTransaction { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - -impl Ord for TokenTransaction { - fn cmp(&self, other: &Self) -> Ordering { - self.tx_id.cmp(&other.tx_id) - } -} - #[async_trait::async_trait] pub trait ApiServerStorageRead: Sync { async fn is_initialized(&self) -> Result; @@ -630,10 +617,9 @@ pub trait ApiServerStorageRead: Sync { address: &str, ) -> Result>, ApiServerStorageError>; - /// Return a page of TX IDs that reference this token_id, with a limit of len and older - /// tx_global_index than the specified. - /// The tx_global_index is only ordered by block height and are not continuous for a specific - /// token_id. + /// 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, @@ -866,12 +852,15 @@ pub trait ApiServerStorageWrite: ApiServerStorageRead { block_height: BlockHeight, ) -> Result<(), ApiServerStorageError>; - /// Append new token transactions with increasing tx_global_index at this block height - async fn set_token_transactions_at_height( + /// 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_ids: BTreeSet>, + transaction_id: Id, block_height: BlockHeight, + tx_global_index: u64, ) -> Result<(), ApiServerStorageError>; async fn set_mainchain_block( diff --git a/api-server/scanner-lib/src/blockchain_state/mod.rs b/api-server/scanner-lib/src/blockchain_state/mod.rs index c8048b109..f47987be6 100644 --- a/api-server/scanner-lib/src/blockchain_state/mod.rs +++ b/api-server/scanner-lib/src/blockchain_state/mod.rs @@ -161,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, @@ -179,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"); @@ -1014,12 +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, + tx_global_index, ) .await .expect("Unable to update tables from transaction inputs"); @@ -1029,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"); @@ -1044,28 +1046,28 @@ async fn update_tables_from_transaction_inputs( db_tx: &mut T, block_height: BlockHeight, 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 token_transactions: BTreeMap>> = BTreeMap::new(); + let mut transaction_tokens: BTreeSet = BTreeSet::new(); - let update_token_transactions_from_order = - |order: &Order, token_transactions: &mut BTreeMap<_, BTreeSet<_>>| { - match order.give_currency { - CoinOrTokenId::TokenId(token_id) => { - token_transactions.entry(token_id).or_default().insert(tx.get_id()); - } - CoinOrTokenId::Coin => {} + 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); } - match order.ask_currency { - CoinOrTokenId::TokenId(token_id) => { - token_transactions.entry(token_id).or_default().insert(tx.get_id()); - } - CoinOrTokenId::Coin => {} + 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 { @@ -1101,7 +1103,7 @@ async fn update_tables_from_transaction_inputs( block_height, ) .await; - token_transactions.entry(*token_id).or_default().insert(tx.get_id()); + transaction_tokens.insert(*token_id); } AccountCommand::UnmintTokens(token_id) => { let total_burned = @@ -1129,7 +1131,7 @@ async fn update_tables_from_transaction_inputs( block_height, ) .await; - token_transactions.entry(*token_id).or_default().insert(tx.get_id()); + transaction_tokens.insert(*token_id); } AccountCommand::FreezeToken(token_id, is_unfreezable) => { let issuance = @@ -1154,7 +1156,7 @@ async fn update_tables_from_transaction_inputs( block_height, ) .await; - token_transactions.entry(*token_id).or_default().insert(tx.get_id()); + transaction_tokens.insert(*token_id); } AccountCommand::UnfreezeToken(token_id) => { let issuance = @@ -1179,7 +1181,7 @@ async fn update_tables_from_transaction_inputs( block_height, ) .await; - token_transactions.entry(*token_id).or_default().insert(tx.get_id()); + transaction_tokens.insert(*token_id); } AccountCommand::LockTokenSupply(token_id) => { let issuance = @@ -1204,7 +1206,7 @@ async fn update_tables_from_transaction_inputs( block_height, ) .await; - token_transactions.entry(*token_id).or_default().insert(tx.get_id()); + transaction_tokens.insert(*token_id); } AccountCommand::ChangeTokenAuthority(token_id, destination) => { let issuance = @@ -1229,7 +1231,7 @@ async fn update_tables_from_transaction_inputs( block_height, ) .await; - token_transactions.entry(*token_id).or_default().insert(tx.get_id()); + transaction_tokens.insert(*token_id); } AccountCommand::ChangeTokenMetadataUri(token_id, metadata_uri) => { let issuance = @@ -1254,7 +1256,7 @@ async fn update_tables_from_transaction_inputs( block_height, ) .await; - token_transactions.entry(*token_id).or_default().insert(tx.get_id()); + 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"); @@ -1263,7 +1265,7 @@ async fn update_tables_from_transaction_inputs( db_tx.set_order_at_height(*order_id, &order, block_height).await?; - update_token_transactions_from_order(&order, &mut token_transactions); + update_tokens_in_transaction(&order, &mut transaction_tokens); } AccountCommand::ConcludeOrder(order_id) => { let order = db_tx.get_order(*order_id).await?.expect("must exist"); @@ -1271,7 +1273,7 @@ async fn update_tables_from_transaction_inputs( db_tx.set_order_at_height(*order_id, &order, block_height).await?; - update_token_transactions_from_order(&order, &mut token_transactions); + update_tokens_in_transaction(&order, &mut transaction_tokens); } }, TxInput::OrderAccountCommand(cmd) => match cmd { @@ -1281,14 +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_token_transactions_from_order(&order, &mut token_transactions); + 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_token_transactions_from_order(&order, &mut token_transactions); + update_tokens_in_transaction(&order, &mut transaction_tokens); } OrderAccountCommand::FreezeOrder(order_id) => { let order = db_tx.get_order(*order_id).await?.expect("must exist"); @@ -1456,7 +1458,7 @@ async fn update_tables_from_transaction_inputs( block_height, ) .await; - token_transactions.entry(token_id).or_default().insert(tx.get_id()); + transaction_tokens.insert(token_id); } TxOutput::Htlc(output_value, htlc) => { let address = if let InputWitness::Standard(sig) = sig { @@ -1487,10 +1489,7 @@ async fn update_tables_from_transaction_inputs( match output_value { OutputValue::TokenV0(_) => {} OutputValue::TokenV1(token_id, _) => { - token_transactions - .entry(token_id) - .or_default() - .insert(tx.get_id()); + transaction_tokens.insert(token_id); } OutputValue::Coin(_) => {} } @@ -1516,10 +1515,7 @@ async fn update_tables_from_transaction_inputs( block_height, ) .await; - token_transactions - .entry(token_id) - .or_default() - .insert(tx.get_id()); + transaction_tokens.insert(token_id); } OutputValue::Coin(amount) => { decrease_address_amount( @@ -1549,9 +1545,10 @@ async fn update_tables_from_transaction_inputs( ) })?; } - for (token_id, transactions) in token_transactions { + let tx_id = tx.get_id(); + for token_id in transaction_tokens { db_tx - .set_token_transactions_at_height(token_id, transactions, block_height) + .set_token_transaction_at_height(token_id, tx_id, block_height, tx_global_index) .await .map_err(|_| { ApiServerStorageError::LowLevelStorageError( @@ -1568,16 +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 token_transactions: BTreeMap>> = 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 { @@ -1586,7 +1585,7 @@ async fn update_tables_from_transaction_outputs( continue; } OutputValue::TokenV1(token_id, amount) => { - token_transactions.entry(*token_id).or_default().insert(transaction_id); + transaction_tokens.insert(*token_id); (CoinOrTokenId::TokenId(*token_id), amount) } }; @@ -1660,13 +1659,13 @@ async fn update_tables_from_transaction_outputs( block_height, ) .await; - token_transactions.entry(token_id).or_default().insert(transaction_id); + 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); - token_transactions.entry(*token_id).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) @@ -1767,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 @@ -1816,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, @@ -1835,7 +1834,7 @@ async fn update_tables_from_transaction_outputs( block_height, ) .await; - token_transactions.entry(*token_id).or_default().insert(transaction_id); + transaction_tokens.insert(*token_id); Some(token_decimals(*token_id, &BTreeMap::new(), db_tx).await?.1) } OutputValue::Coin(amount) => { @@ -1851,8 +1850,7 @@ 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) @@ -1863,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, @@ -1902,7 +1899,7 @@ async fn update_tables_from_transaction_outputs( } OutputValue::TokenV0(_) => None, OutputValue::TokenV1(token_id, amount) => { - token_transactions.entry(*token_id).or_default().insert(transaction_id); + transaction_tokens.insert(*token_id); if already_unlocked { increase_address_amount( db_tx, @@ -1951,30 +1948,23 @@ async fn update_tables_from_transaction_outputs( Address::::new(&chain_config, htlc.spend_key.clone()) .expect("Unable to encode destination"); - address_transactions - .entry(spend_address.clone()) - .or_default() - .insert(transaction_id); + address_transactions.entry(spend_address.clone()).or_default().insert(tx_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(transaction_id); + 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, _) => { - token_transactions.entry(*token_id).or_default().insert(transaction_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( @@ -1991,7 +1981,7 @@ async fn update_tables_from_transaction_outputs( let mut amount_and_currency = |v: &OutputValue| match v { OutputValue::Coin(amount) => (CoinOrTokenId::Coin, *amount), OutputValue::TokenV1(id, amount) => { - token_transactions.entry(*id).or_default().insert(transaction_id); + transaction_tokens.insert(*id); (CoinOrTokenId::TokenId(*id), *amount) } OutputValue::TokenV0(_) => panic!("unsupported token"), @@ -2032,9 +2022,9 @@ async fn update_tables_from_transaction_outputs( })?; } - for (token_id, transactions) in token_transactions { + for token_id in transaction_tokens { db_tx - .set_token_transactions_at_height(token_id, transactions, block_height) + .set_token_transaction_at_height(token_id, tx_id, block_height, tx_global_index) .await .map_err(|_| { ApiServerStorageError::LowLevelStorageError( diff --git a/api-server/scanner-lib/src/sync/tests/mod.rs b/api-server/scanner-lib/src/sync/tests/mod.rs index f54fb86e4..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, - AccountCommand, AccountNonce, CoinUnit, Destination, OrderId, OutPointSourceId, PoolId, - SignedTransaction, Transaction, 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}, }; @@ -1263,12 +1267,6 @@ async fn check_all_destinations_are_tracked(#[case] seed: Seed) { #[case(test_utils::random::Seed::from_entropy())] #[tokio::test] async fn token_transactions_storage_check(#[case] seed: Seed) { - use common::chain::{ - make_order_id, make_token_id, - tokens::{IsTokenUnfreezable, TokenIssuance}, - AccountCommand, AccountNonce, OrderAccountCommand, OrderData, - }; - let mut rng = make_seedable_rng(seed); let mut tf = TestFramework::builder(&mut rng).build(); @@ -1627,11 +1625,6 @@ async fn token_transactions_storage_check(#[case] seed: Seed) { #[case(test_utils::random::Seed::from_entropy())] #[tokio::test] async fn htlc_addresses_storage_check(#[case] seed: Seed) { - use common::chain::{ - htlc::{HashedTimelockContract, HtlcSecret}, - signature::inputsig::authorize_hashed_timelock_contract_spend::AuthorizedHashedTimelockContractSpend, - }; - let mut rng = make_seedable_rng(seed); let mut tf = TestFramework::builder(&mut rng).build(); diff --git a/api-server/storage-test-suite/src/basic.rs b/api-server/storage-test-suite/src/basic.rs index 0d9cb7fbe..e9b02fd77 100644 --- a/api-server/storage-test-suite/src/basic.rs +++ b/api-server/storage-test-suite/src/basic.rs @@ -950,9 +950,9 @@ where }) .collect(); - for (_, tx_id, block_height) in &token_transactions { + for (idx, tx_id, block_height) in &token_transactions { db_tx - .set_token_transactions_at_height(random_token_id, [*tx_id].into(), *block_height) + .set_token_transaction_at_height(random_token_id, *tx_id, *block_height, *idx) .await .unwrap(); } From 7d34a4867abcf7f37e83293bf7634ed5db73d7d0 Mon Sep 17 00:00:00 2001 From: Boris Oncev Date: Wed, 10 Dec 2025 05:46:07 +0100 Subject: [PATCH 5/5] fix comments --- .../src/storage/impls/in_memory/mod.rs | 10 +- .../src/storage/impls/postgres/queries.rs | 4 +- .../src/storage/storage_api/mod.rs | 4 +- .../scanner-lib/src/blockchain_state/mod.rs | 15 +- api-server/scanner-lib/src/sync/tests/mod.rs | 244 ++++++++++++------ .../stack-test-suite/tests/v2/helpers.rs | 2 + .../tests/v2/token_transactions.rs | 104 ++------ api-server/storage-test-suite/src/basic.rs | 180 ++++++++++--- 8 files changed, 357 insertions(+), 206 deletions(-) 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 51fbae6f5..6c2c9f6cd 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 @@ -22,6 +22,8 @@ use std::{ sync::Arc, }; +use itertools::Itertools as _; + use common::{ address::Address, chain::{ @@ -40,8 +42,6 @@ use crate::storage::storage_api::{ TokenTransaction, TransactionInfo, TransactionWithBlockInfo, Utxo, UtxoLock, UtxoWithExtraInfo, }; -use itertools::Itertools as _; - use super::CURRENT_STORAGE_VERSION; #[derive(Debug, Clone, PartialEq, Eq)] @@ -207,7 +207,11 @@ impl ApiServerInMemoryStorage { transactions .iter() .rev() - .flat_map(|(_, txs)| txs.iter().map(|tx| &tx.0)) + .flat_map(|(_, txs)| { + let mut txs: Vec<_> = txs.iter().map(|tx| &tx.0).collect(); + txs.sort_by_key(|tx| std::cmp::Reverse(tx.tx_global_index)); + txs + }) .flat_map(|tx| (tx.tx_global_index < tx_global_index).then_some(tx)) .cloned() .take(len as usize) 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 8d7ba5d94..0c0dc910b 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 @@ -616,8 +616,8 @@ impl<'a, 'b> QueryFromConnection<'a, 'b> { 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; + ON CONFLICT (token_id, transaction_id, block_height) DO UPDATE + SET tx_global_index = $4; "#, &[&token_id.encode(), &height, &transaction_id.encode(), &tx_global_index], ) 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 09a67759d..fe4fdd895 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,7 +68,7 @@ pub enum ApiServerStorageError { AddressableError, #[error("Block timestamp too high: {0}")] TimestampTooHigh(BlockTimestamp), - #[error("Tx global index too hight: {0}")] + #[error("Tx global index too high: {0}")] TxGlobalIndexTooHigh(u64), #[error("Id creation error: {0}")] IdCreationError(#[from] IdCreationError), @@ -619,7 +619,7 @@ pub trait ApiServerStorageRead: Sync { /// 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`. + /// The `tx_global_index` is not continuous for a specific `token_id`. async fn get_token_transactions( &self, token_id: TokenId, diff --git a/api-server/scanner-lib/src/blockchain_state/mod.rs b/api-server/scanner-lib/src/blockchain_state/mod.rs index f47987be6..0fc2873d1 100644 --- a/api-server/scanner-lib/src/blockchain_state/mod.rs +++ b/api-server/scanner-lib/src/blockchain_state/mod.rs @@ -201,11 +201,11 @@ impl LocalBlockchainState for BlockchainState .await .expect("Unable to set block"); - for (i, tx_info) in transactions.iter().enumerate() { + for (idx, tx_info) in transactions.iter().enumerate() { db_tx .set_transaction( tx_info.tx.transaction().get_id(), - next_order_number + i as u64, + next_order_number + idx as u64, block_id, tx_info, ) @@ -1054,7 +1054,7 @@ async fn update_tables_from_transaction_inputs( BTreeMap::new(); let mut transaction_tokens: BTreeSet = BTreeSet::new(); - let update_tokens_in_transaction = |order: &Order, tokens_in_transaction: &mut BTreeSet<_>| { + let update_tokens_for_order = |order: &Order, tokens_in_transaction: &mut BTreeSet<_>| { match order.give_currency { CoinOrTokenId::TokenId(token_id) => { tokens_in_transaction.insert(token_id); @@ -1265,7 +1265,7 @@ async fn update_tables_from_transaction_inputs( db_tx.set_order_at_height(*order_id, &order, block_height).await?; - update_tokens_in_transaction(&order, &mut transaction_tokens); + update_tokens_for_order(&order, &mut transaction_tokens); } AccountCommand::ConcludeOrder(order_id) => { let order = db_tx.get_order(*order_id).await?.expect("must exist"); @@ -1273,7 +1273,7 @@ async fn update_tables_from_transaction_inputs( db_tx.set_order_at_height(*order_id, &order, block_height).await?; - update_tokens_in_transaction(&order, &mut transaction_tokens); + update_tokens_for_order(&order, &mut transaction_tokens); } }, TxInput::OrderAccountCommand(cmd) => match cmd { @@ -1283,20 +1283,21 @@ 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); + update_tokens_for_order(&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); + update_tokens_for_order(&order, &mut transaction_tokens); } OrderAccountCommand::FreezeOrder(order_id) => { let order = db_tx.get_order(*order_id).await?.expect("must exist"); let order = order.freeze(); db_tx.set_order_at_height(*order_id, &order, block_height).await?; + update_tokens_for_order(&order, &mut transaction_tokens); } }, TxInput::Account(outpoint) => { diff --git a/api-server/scanner-lib/src/sync/tests/mod.rs b/api-server/scanner-lib/src/sync/tests/mod.rs index 4f1fc1d1f..6ff7486ed 100644 --- a/api-server/scanner-lib/src/sync/tests/mod.rs +++ b/api-server/scanner-lib/src/sync/tests/mod.rs @@ -17,7 +17,7 @@ mod simulation; use std::{ borrow::Cow, - collections::BTreeMap, + collections::{BTreeMap, BTreeSet}, convert::Infallible, sync::{Arc, Mutex}, time::Duration, @@ -1361,13 +1361,21 @@ async fn token_transactions_storage_check(#[case] seed: Seed) { tf.process_block(block2.clone(), BlockSource::Local).unwrap(); local_state.scan_blocks(BlockHeight::new(2), vec![block2]).await.unwrap(); + let mut token_txs = BTreeSet::new(); + token_txs.insert(tx_issue_id); + token_txs.insert(tx_mint_id); + // 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); + let txs = db_tx + .get_token_transactions(token_id, 100, u64::MAX) + .await + .unwrap() + .into_iter() + .map(|t| t.tx_id) + .collect::>(); + assert_eq!(txs, token_txs); 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 @@ -1442,15 +1450,21 @@ async fn token_transactions_storage_check(#[case] seed: Seed) { tf.process_block(block3.clone(), BlockSource::Local).unwrap(); local_state.scan_blocks(BlockHeight::new(3), vec![block3]).await.unwrap(); + token_txs.insert(tx_freeze_id); + token_txs.insert(tx_unfreeze_id); + token_txs.insert(tx_metadata_id); + token_txs.insert(tx_authority_id); + // 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)); + let txs = db_tx + .get_token_transactions(token_id, 100, u64::MAX) + .await + .unwrap() + .into_iter() + .map(|t| t.tx_id) + .collect::>(); + assert_eq!(txs, token_txs); drop(db_tx); // ------------------------------------------------------------------------ @@ -1482,10 +1496,16 @@ async fn token_transactions_storage_check(#[case] seed: Seed) { local_state.scan_blocks(BlockHeight::new(4), vec![block4]).await.unwrap(); // Verify Storage: 6 previous + 1 spend = 7 + token_txs.insert(tx_spend_id); 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)); + let txs = db_tx + .get_token_transactions(token_id, 100, u64::MAX) + .await + .unwrap() + .into_iter() + .map(|t| t.tx_id) + .collect::>(); + assert_eq!(txs, token_txs); drop(db_tx); // ------------------------------------------------------------------------ @@ -1533,10 +1553,16 @@ async fn token_transactions_storage_check(#[case] seed: Seed) { // 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(); + let txs = db_tx + .get_token_transactions(token_id, 100, u64::MAX) + .await + .unwrap() + .into_iter() + .map(|t| t.tx_id) + .collect::>(); // 7 prev + 1 creation = 8 - assert_eq!(txs.len(), 8); - assert!(txs.iter().any(|t| t.tx_id == tx_create_order_id)); + token_txs.insert(tx_create_order_id); + assert_eq!(txs, token_txs); drop(db_tx); // 4b. Fill Order @@ -1577,28 +1603,34 @@ async fn token_transactions_storage_check(#[case] seed: Seed) { // 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(); + let txs = db_tx + .get_token_transactions(token_id, 100, u64::MAX) + .await + .unwrap() + .into_iter() + .map(|t| t.tx_id) + .collect::>(); // 8 prev + 1 fill = 9 - assert_eq!(txs.len(), 9); - assert!(txs.iter().any(|t| t.tx_id == tx_fill_id)); + token_txs.insert(tx_fill_id); + assert_eq!(txs, token_txs); drop(db_tx); - // 4c. Conclude Order - let tx_conclude = TransactionBuilder::new() + // 4c. Freeze Order + let tx_freeze = TransactionBuilder::new() .add_input( TxInput::from_utxo(OutPointSourceId::Transaction(tx_fill_id), 0), InputWitness::NoSignature(None), ) .add_input( - TxInput::OrderAccountCommand(OrderAccountCommand::ConcludeOrder(order_id)), + TxInput::OrderAccountCommand(OrderAccountCommand::FreezeOrder(order_id)), InputWitness::NoSignature(None), ) .add_output(TxOutput::Transfer( - OutputValue::Coin(Amount::from_atoms(80000)), + OutputValue::Coin(coins_amount), Destination::AnyoneCanSpend, )) .build(); - let tx_conclude_id = tx_conclude.transaction().get_id(); + let tx_freeze_id = tx_freeze.transaction().get_id(); // Process Block 7 tf.progress_time_seconds_since_epoch(target_block_time.as_secs()); @@ -1606,17 +1638,65 @@ async fn token_transactions_storage_check(#[case] seed: Seed) { let block7 = tf .make_block_builder() .with_parent(best_block_id) - .with_transactions(vec![tx_conclude.clone()]) + .with_transactions(vec![tx_freeze.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)); + let txs = db_tx + .get_token_transactions(token_id, 100, u64::MAX) + .await + .unwrap() + .into_iter() + .map(|t| t.tx_id) + .collect::>(); + // 9 prev + 1 freeze = 10 + token_txs.insert(tx_freeze_id); + assert_eq!(txs, token_txs); + drop(db_tx); + + // 4d. Conclude Order + let tx_conclude = TransactionBuilder::new() + .add_input( + TxInput::from_utxo(OutPointSourceId::Transaction(tx_freeze_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 8 + tf.progress_time_seconds_since_epoch(target_block_time.as_secs()); + let best_block_id = tf.best_block_id(); + let block8 = tf + .make_block_builder() + .with_parent(best_block_id) + .with_transactions(vec![tx_conclude.clone()]) + .build(&mut rng); + tf.process_block(block8.clone(), BlockSource::Local).unwrap(); + local_state.scan_blocks(BlockHeight::new(8), vec![block8]).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() + .into_iter() + .map(|t| t.tx_id) + .collect::>(); + // 10 prev + 1 conclude = 10 + token_txs.insert(tx_conclude_id); + assert_eq!(txs, token_txs); drop(db_tx); } @@ -1694,38 +1774,49 @@ async fn htlc_addresses_storage_check(#[case] seed: Seed) { // Verify Storage let db_tx = local_state.storage().transaction_ro().await.unwrap(); + let expected_txs = BTreeSet::from([tx_fund_id]); + // 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" - ); + let spend_txs = db_tx + .get_address_transactions(spend_address.as_str()) + .await + .unwrap() + .into_iter() + .collect::>(); + assert_eq!(spend_txs, expected_txs); // 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 refund_txs = db_tx + .get_address_transactions(refund_address.as_str()) + .await + .unwrap() + .into_iter() + .collect::>(); + assert_eq!(refund_txs, expected_txs); - 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) - )); + let expected_utxso = BTreeSet::from([ + UtxoOutPoint::new(OutPointSourceId::Transaction(tx_fund_id), 0), + UtxoOutPoint::new(OutPointSourceId::Transaction(tx_fund_id), 1), + ]); + + let utxos = db_tx + .get_address_available_utxos(spend_address.as_str()) + .await + .unwrap() + .into_iter() + .map(|(outpoint, _)| outpoint) + .collect::>(); + assert_eq!(utxos, expected_utxso); + let utxos = db_tx + .get_address_available_utxos(refund_address.as_str()) + .await + .unwrap() + .into_iter() + .map(|(outpoint, _)| outpoint) + .collect::>(); + assert_eq!(utxos, expected_utxso); drop(db_tx); // ------------------------------------------------------------------------ @@ -1858,36 +1949,39 @@ async fn htlc_addresses_storage_check(#[case] seed: Seed) { // ------------------------------------------------------------------------ let db_tx = local_state.storage().transaction_ro().await.unwrap(); + let mut expected_spend_address_txs = expected_txs.clone(); // A. Check Spend Address Transactions // Should see Fund Tx (because it's the spend authority in the outputs) + expected_spend_address_txs.insert(tx_fund_id); // 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" - ); + expected_spend_address_txs.insert(tx_spend_id); + + let spend_txs = db_tx + .get_address_transactions(spend_address.as_str()) + .await + .unwrap() + .into_iter() + .collect::>(); + assert_eq!(spend_txs, expected_spend_address_txs); // Should NOT contain refund tx assert!( !spend_txs.contains(&tx_refund_id), "Spend address has refund tx" ); + let mut expected_refund_address_txs = expected_txs.clone(); // B. Check Refund Address Transactions // Should see Fund Tx (because it's the refund authority in the outputs) + expected_refund_address_txs.insert(tx_fund_id); // 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" - ); + expected_refund_address_txs.insert(tx_refund_id); + let refund_txs = db_tx + .get_address_transactions(refund_address.as_str()) + .await + .unwrap() + .into_iter() + .collect::>(); + assert_eq!(refund_txs, expected_refund_address_txs); // Should NOT contain spend tx assert!( !refund_txs.contains(&tx_spend_id), diff --git a/api-server/stack-test-suite/tests/v2/helpers.rs b/api-server/stack-test-suite/tests/v2/helpers.rs index 4390c2128..f9d04f1fa 100644 --- a/api-server/stack-test-suite/tests/v2/helpers.rs +++ b/api-server/stack-test-suite/tests/v2/helpers.rs @@ -149,6 +149,7 @@ pub struct IssueAndMintTokensResult { pub change_outpoint: UtxoOutPoint, pub tokens_outpoint: UtxoOutPoint, + pub minted_tokens: Amount, } pub fn issue_and_mint_tokens_from_genesis( @@ -240,5 +241,6 @@ pub fn issue_and_mint_tokens_from_genesis( mint_block: block2, change_outpoint: UtxoOutPoint::new(tx2_id.into(), 0), tokens_outpoint: UtxoOutPoint::new(tx2_id.into(), 1), + minted_tokens: amount_to_mint, } } diff --git a/api-server/stack-test-suite/tests/v2/token_transactions.rs b/api-server/stack-test-suite/tests/v2/token_transactions.rs index 37f7f5768..76a934c04 100644 --- a/api-server/stack-test-suite/tests/v2/token_transactions.rs +++ b/api-server/stack-test-suite/tests/v2/token_transactions.rs @@ -17,14 +17,12 @@ use serde_json::Value; use chainstate_test_framework::empty_witness; use common::{ - chain::{ - make_token_id, - tokens::{TokenId, TokenIssuance, TokenTotalSupply}, - AccountCommand, AccountNonce, UtxoOutPoint, - }, + chain::{tokens::TokenId, AccountCommand, AccountNonce, UtxoOutPoint}, primitives::H256, }; +use crate::v2::helpers::{issue_and_mint_tokens_from_genesis, IssueAndMintTokensResult}; + use super::*; #[tokio::test] @@ -121,23 +119,6 @@ async fn ok(#[case] seed: Seed) { .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(), @@ -145,69 +126,38 @@ async fn ok(#[case] seed: Seed) { .unwrap() .coin_amount() .unwrap(); + + let token_issuance_fee = + tf.chainstate.get_chain_config().fungible_token_issuance_fee(); + + let min_amount_to_mint = Amount::from_atoms(100); + let IssueAndMintTokensResult { + token_id, + issue_block, + mint_block, + change_outpoint, + tokens_outpoint, + minted_tokens, + } = issue_and_mint_tokens_from_genesis(min_amount_to_mint, &mut rng, &mut tf); + 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 = &issue_block.transactions()[0]; 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 = &mint_block.transactions()[0]; 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 tokens_left_after_unmint = (minted_tokens - tokens_to_unmint).unwrap(); let unmint_tokens_tx = TransactionBuilder::new() .add_input( TxInput::from_command( @@ -216,20 +166,14 @@ async fn ok(#[case] seed: Seed) { ), 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_input(TxInput::Utxo(change_outpoint), empty_witness(&mut rng)) + .add_input(TxInput::Utxo(tokens_outpoint), 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), + OutputValue::TokenV1(token_id, tokens_left_after_unmint), Destination::AnyoneCanSpend, )) .add_output(TxOutput::Burn(OutputValue::TokenV1( @@ -288,7 +232,7 @@ async fn ok(#[case] seed: Seed) { token_transactions, )); - vec![block1, block2, block3, block4] + vec![issue_block, mint_block, block3, block4] }; let storage = { diff --git a/api-server/storage-test-suite/src/basic.rs b/api-server/storage-test-suite/src/basic.rs index e9b02fd77..ecdc5b3da 100644 --- a/api-server/storage-test-suite/src/basic.rs +++ b/api-server/storage-test-suite/src/basic.rs @@ -127,7 +127,7 @@ where assert_eq!(timestamps, &[genesis_timestamp]); { - let random_block_id: Id = Id::::new(H256::random_using(&mut rng)); + let random_block_id = Id::::random_using(&mut rng); let block = db_tx.get_block(random_block_id).await.unwrap(); assert!(block.is_none()); } @@ -476,7 +476,7 @@ where { let db_tx = storage.transaction_ro().await.unwrap(); - let random_tx_id: Id = Id::::new(H256::random_using(&mut rng)); + let random_tx_id = Id::::random_using(&mut rng); let tx = db_tx.get_transaction(random_tx_id).await.unwrap(); assert!(tx.is_none()); @@ -484,9 +484,7 @@ where let tx1: SignedTransaction = TransactionBuilder::new() .add_input( TxInput::Utxo(UtxoOutPoint::new( - OutPointSourceId::Transaction(Id::::new(H256::random_using( - &mut rng, - ))), + OutPointSourceId::Transaction(Id::::random_using(&mut rng)), 0, )), empty_witness(&mut rng), @@ -518,7 +516,7 @@ where token_decimals: BTreeMap::new(), }, }; - let random_owning_block = Id::::new(H256::random_using(&mut rng)); + let random_owning_block = Id::::random_using(&mut rng); let result = db_tx .set_transaction(tx1.transaction().get_id(), 1, random_owning_block, &tx_info) .await @@ -603,7 +601,7 @@ where { let mut db_tx = storage.transaction_rw().await.unwrap(); - let random_block_id: Id = Id::::new(H256::random_using(&mut rng)); + let random_block_id = Id::::random_using(&mut rng); let random_block_timestamp = BlockTimestamp::from_int_seconds(rng.gen::()); let block = db_tx.get_block_aux_data(random_block_id).await.unwrap(); assert!(block.is_none()); @@ -651,7 +649,7 @@ where let mut db_tx = storage.transaction_rw().await.unwrap(); - let random_tx_id: Id = Id::::new(H256::random_using(&mut rng)); + let random_tx_id = Id::::random_using(&mut rng); let outpoint = UtxoOutPoint::new( OutPointSourceId::Transaction(random_tx_id), rng.gen::(), @@ -767,8 +765,7 @@ where let bob_address = Address::::new(&chain_config, bob_destination.clone()).unwrap(); - let random_tx_id: Id = - Id::::new(H256::random_using(&mut rng)); + let random_tx_id = Id::::random_using(&mut rng); let outpoint = UtxoOutPoint::new( OutPointSourceId::Transaction(random_tx_id), rng.gen::(), @@ -820,8 +817,7 @@ where .unwrap(); // set another locked utxo - let random_tx_id: Id = - Id::::new(H256::random_using(&mut rng)); + let random_tx_id = Id::::random_using(&mut rng); let locked_outpoint = UtxoOutPoint::new( OutPointSourceId::Transaction(random_tx_id), rng.gen::(), @@ -880,8 +876,7 @@ where // set another one and retrieve both { - let random_tx_id: Id = - Id::::new(H256::random_using(&mut rng)); + let random_tx_id = Id::::random_using(&mut rng); let outpoint2 = UtxoOutPoint::new( OutPointSourceId::Transaction(random_tx_id), rng.gen::(), @@ -940,11 +935,122 @@ where // Test token transactions { + // Check geting token_transactions from same block height are sorted by tx_global_index let mut db_tx = storage.transaction_rw().await.unwrap(); - let random_token_id = TokenId::new(H256::random_using(&mut rng)); + let random_token_id = TokenId::random_using(&mut rng); + let block_height = BlockHeight::new(100); + + let token_transactions: Vec<_> = (0..10) + .map(|idx| { + let random_tx_id = Id::::random_using(&mut rng); + TokenTransaction { + tx_global_index: idx, + tx_id: random_tx_id, + } + }) + .collect(); + + for tx in &token_transactions { + db_tx + .set_token_transaction_at_height( + random_token_id, + tx.tx_id, + block_height, + tx.tx_global_index, + ) + .await + .unwrap(); + } + + let len = 5; + let global_idx = 10; + let token_txs = + db_tx.get_token_transactions(random_token_id, len, global_idx).await.unwrap(); + + let expected_txs: Vec<_> = token_transactions + .iter() + .rev() + .filter(|tx| tx.tx_global_index < global_idx) + .take(len as usize) + .cloned() + .collect(); + assert_eq!(token_txs, expected_txs); + + let len = 5; + let global_idx = 5; + let token_txs = + db_tx.get_token_transactions(random_token_id, len, global_idx).await.unwrap(); + + let expected_txs: Vec<_> = token_transactions + .iter() + .rev() + .filter(|tx| tx.tx_global_index < global_idx) + .take(len as usize) + .cloned() + .collect(); + assert_eq!(token_txs, expected_txs); + + // Set again the same txs, the tx_global_index should be updated + let updated_token_transactions: Vec<_> = token_transactions + .iter() + .map(|tx| { + let random_tx_id = tx.tx_id; + TokenTransaction { + tx_global_index: tx.tx_global_index + 100, // shift indexes so they are clearly different + tx_id: random_tx_id, + } + }) + .collect(); + + for tx in &updated_token_transactions { + db_tx + .set_token_transaction_at_height( + random_token_id, + tx.tx_id, + block_height, + tx.tx_global_index, + ) + .await + .unwrap(); + } + + let len = 5; + let global_idx = 200; + let token_txs = + db_tx.get_token_transactions(random_token_id, len, global_idx).await.unwrap(); + + let expected_txs: Vec<_> = updated_token_transactions + .iter() + .rev() + .filter(|tx| tx.tx_global_index < global_idx) + .take(len as usize) + .cloned() + .collect(); + + assert_eq!(token_txs, expected_txs); + + let len = 5; + let global_idx = 105; + let token_txs = + db_tx.get_token_transactions(random_token_id, len, global_idx).await.unwrap(); + + let expected_txs: Vec<_> = updated_token_transactions + .iter() + .rev() + .filter(|tx| tx.tx_global_index < global_idx) + .take(len as usize) + .cloned() + .collect(); + + assert_eq!(token_txs, expected_txs); + + drop(db_tx); + + let mut db_tx = storage.transaction_rw().await.unwrap(); + let random_token_id = TokenId::random_using(&mut rng); let token_transactions: Vec<_> = (0..10) .map(|idx| { - let random_tx_id = Id::::new(H256::random_using(&mut rng)); + let random_tx_id = Id::::random_using(&mut rng); let block_height = BlockHeight::new(idx); (idx, random_tx_id, block_height) }) @@ -984,7 +1090,7 @@ where // test missing random pool data { - let random_pool_id = PoolId::new(H256::random_using(&mut rng)); + let random_pool_id = PoolId::random_using(&mut rng); let pool_data = db_tx.get_pool_data(random_pool_id).await.unwrap(); assert!(pool_data.is_none()); @@ -996,7 +1102,7 @@ where } { - let random_pool_id = PoolId::new(H256::random_using(&mut rng)); + let random_pool_id = PoolId::random_using(&mut rng); let random_block_height = BlockHeight::new(rng.gen::() as u64); let (_, vrf_pk) = VRFPrivateKey::new_from_rng(&mut rng, VRFKeyKind::Schnorrkel); let amount_to_stake = Amount::from_atoms(rng.gen::()); @@ -1027,7 +1133,7 @@ where assert_eq!(pool_data, random_pool_data); // insert a second pool data - let random_pool_id2 = PoolId::new(H256::random_using(&mut rng)); + let random_pool_id2 = PoolId::random_using(&mut rng); let (_, vrf_pk) = VRFPrivateKey::new_from_rng(&mut rng, VRFKeyKind::Schnorrkel); let (_, pk) = PrivateKey::new_from_rng(&mut rng, KeyKind::Secp256k1Schnorr); let amount_to_stake = { @@ -1202,15 +1308,15 @@ where // test missing random pool data { - let random_delegation_id = DelegationId::new(H256::random_using(&mut rng)); + let random_delegation_id = DelegationId::random_using(&mut rng); let delegation_data = db_tx.get_delegation(random_delegation_id).await.unwrap(); assert!(delegation_data.is_none()); } { let (random_delegation_id, random_delegation_id2) = { - let id1 = DelegationId::new(H256::random_using(&mut rng)); - let id2 = DelegationId::new(H256::random_using(&mut rng)); + let id1 = DelegationId::random_using(&mut rng); + let id2 = DelegationId::random_using(&mut rng); if id1 < id2 { (id1, id2) @@ -1223,8 +1329,8 @@ where let random_block_height2 = BlockHeight::new(rng.gen_range(1..500) as u64); let (_, pk) = PrivateKey::new_from_rng(&mut rng, KeyKind::Secp256k1Schnorr); - let random_pool_id = PoolId::new(H256::random_using(&mut rng)); - let random_pool_id2 = PoolId::new(H256::random_using(&mut rng)); + let random_pool_id = PoolId::random_using(&mut rng); + let random_pool_id2 = PoolId::random_using(&mut rng); let random_balance = Amount::from_atoms(rng.gen::()); let random_balance2 = Amount::from_atoms(rng.gen::()); let random_nonce = AccountNonce::new(rng.gen::()); @@ -1364,7 +1470,7 @@ where { let db_tx = storage.transaction_ro().await.unwrap(); - let random_token_id = TokenId::new(H256::random_using(&mut rng)); + let random_token_id = TokenId::random_using(&mut rng); let nft = db_tx.get_nft_token_issuance(random_token_id).await.unwrap(); assert!(nft.is_none()); @@ -1466,7 +1572,7 @@ where { let db_tx = storage.transaction_ro().await.unwrap(); - let random_token_id = TokenId::new(H256::random_using(&mut rng)); + let random_token_id = TokenId::random_using(&mut rng); let token = db_tx.get_fungible_token_issuance(random_token_id).await.unwrap(); assert!(token.is_none()); @@ -1624,19 +1730,19 @@ where }; let block_height = BlockHeight::new(rng.gen_range(1..100)); - let random_token_id1 = TokenId::new(H256::random_using(&mut rng)); + let random_token_id1 = TokenId::random_using(&mut rng); db_tx .set_fungible_token_issuance(random_token_id1, block_height, token_data.clone()) .await .unwrap(); - let random_token_id2 = TokenId::new(H256::random_using(&mut rng)); + let random_token_id2 = TokenId::random_using(&mut rng); db_tx .set_fungible_token_issuance(random_token_id2, block_height, token_data.clone()) .await .unwrap(); - let random_token_id3 = TokenId::new(H256::random_using(&mut rng)); + let random_token_id3 = TokenId::random_using(&mut rng); db_tx .set_fungible_token_issuance(random_token_id3, block_height, token_data.clone()) .await @@ -1657,17 +1763,17 @@ where let (_, pk) = PrivateKey::new_from_rng(&mut rng, KeyKind::Secp256k1Schnorr); let random_owner = Destination::PublicKeyHash(PublicKeyHash::from(&pk)); - let random_token_id4 = TokenId::new(H256::random_using(&mut rng)); + let random_token_id4 = TokenId::random_using(&mut rng); db_tx .set_nft_token_issuance(random_token_id4, block_height, nft.clone(), &random_owner) .await .unwrap(); - let random_token_id5 = TokenId::new(H256::random_using(&mut rng)); + let random_token_id5 = TokenId::random_using(&mut rng); db_tx .set_nft_token_issuance(random_token_id5, block_height, nft.clone(), &random_owner) .await .unwrap(); - let random_token_id6 = TokenId::new(H256::random_using(&mut rng)); + let random_token_id6 = TokenId::random_using(&mut rng); db_tx .set_nft_token_issuance(random_token_id6, block_height, nft.clone(), &random_owner) .await @@ -1747,7 +1853,7 @@ where { let db_tx = storage.transaction_ro().await.unwrap(); - let random_token_id = TokenId::new(H256::random_using(&mut rng)); + let random_token_id = TokenId::random_using(&mut rng); let random_coin_or_token_id = CoinOrTokenId::TokenId(random_token_id); let random_statistic = match rng.gen_range(0..4) { 0 => CoinOrTokenStatistic::CirculatingSupply, @@ -1829,7 +1935,7 @@ async fn orders<'a, S: for<'b> Transactional<'b>>( let chain_config = common::chain::config::create_regtest(); { let db_tx = storage.transaction_ro().await.unwrap(); - let random_order_id = OrderId::new(H256::random_using(rng)); + let random_order_id = OrderId::random_using(rng); let order = db_tx.get_order(random_order_id).await.unwrap(); assert!(order.is_none()); @@ -1837,8 +1943,8 @@ async fn orders<'a, S: for<'b> Transactional<'b>>( assert!(orders.is_empty()); } - let token1 = TokenId::new(H256::random_using(rng)); - let token2 = TokenId::new(H256::random_using(rng)); + let token1 = TokenId::random_using(rng); + let token2 = TokenId::random_using(rng); let (order1_id, order1) = random_order( rng, @@ -2040,7 +2146,7 @@ fn random_order( ask_currency: CoinOrTokenId, give_currency: CoinOrTokenId, ) -> (OrderId, Order) { - let order_id = OrderId::new(H256::random_using(rng)); + let order_id = OrderId::random_using(rng); let (_, pk) = PrivateKey::new_from_rng(rng, KeyKind::Secp256k1Schnorr); let conclude_destination = Destination::PublicKeyHash(PublicKeyHash::from(&pk)); let give_amount = Amount::from_atoms(rng.gen_range(1000..10000));