From dbf545961d0006632d1265b36e7db7106a85252e Mon Sep 17 00:00:00 2001 From: Enrico Rubboli Date: Mon, 14 Sep 2026 19:45:46 +0400 Subject: [PATCH] wallet: credit order inputs in PST balance checks --- Cargo.lock | 1 + wallet/wallet-controller/Cargo.toml | 1 + wallet/wallet-controller/src/lib.rs | 208 ++++++++++++- .../src/tests/compose_transaction_tests.rs | 278 +++++++++++++++++- 4 files changed, 475 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 61afb1bd71..ef2fcb06dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10132,6 +10132,7 @@ dependencies = [ "mempool", "mempool-types", "node-comm", + "orders-accounting", "p2p-types", "randomness", "rpc", diff --git a/wallet/wallet-controller/Cargo.toml b/wallet/wallet-controller/Cargo.toml index 8396588330..f48ff3033a 100644 --- a/wallet/wallet-controller/Cargo.toml +++ b/wallet/wallet-controller/Cargo.toml @@ -15,6 +15,7 @@ logging = { path = "../../logging" } mempool-types = { path = "../../mempool/types" } mempool = { path = "../../mempool" } node-comm = { path = "../wallet-node-client", default-features = false } +orders-accounting = { path = "../../orders-accounting" } rpc-description = { path = "../../rpc/description" } randomness = { path = "../../randomness" } serialization = { path = "../../serialization" } diff --git a/wallet/wallet-controller/src/lib.rs b/wallet/wallet-controller/src/lib.rs index 8005e58936..a1176bd8a6 100644 --- a/wallet/wallet-controller/src/lib.rs +++ b/wallet/wallet-controller/src/lib.rs @@ -63,10 +63,12 @@ use synced_controller::SyncedController; use common::{ address::AddressError, chain::{ - Block, ChainConfig, Currency, Destination, GenBlock, PoolId, SighashInputCommitmentVersion, - SignedTransaction, Transaction, TxInput, TxOutput, UtxoOutPoint, + AccountCommand, Block, ChainConfig, Currency, Destination, GenBlock, OrderAccountCommand, + OrderId, PoolId, SighashInputCommitmentVersion, SignedTransaction, Transaction, TxInput, + TxOutput, UtxoOutPoint, block::timestamp::BlockTimestamp, htlc::HtlcSecret, + output_value::OutputValue, signature::{ DestinationSigError, Transactable, inputsig::InputWitness, sighash::input_commitments::SighashInputCommitment, @@ -118,7 +120,7 @@ pub use wallet_types::{ use wallet_types::hw_data::HardwareWalletFullInfo; use wallet_types::{ partially_signed_transaction::{ - PartiallySignedTransaction, PartiallySignedTransactionError, + OrderAdditionalInfo, PartiallySignedTransaction, PartiallySignedTransactionError, PartiallySignedTransactionWalletExt as _, PtxAdditionalInfo, SighashInputCommitmentCreationError, make_sighash_input_commitments, }, @@ -1170,7 +1172,14 @@ where .await?; let only_input_utxos = input_utxos.iter().flatten().cloned().collect_vec(); - let fees = self.get_fees(&only_input_utxos, stx.outputs()).await?; + let fees = self + .get_fees( + stx.inputs(), + &only_input_utxos, + stx.outputs(), + Some(&additional_infos), + ) + .await?; let input_commitments_v0 = make_sighash_input_commitments( stx.inputs(), @@ -1234,7 +1243,14 @@ where ptx: PartiallySignedTransaction, ) -> Result> { let input_utxos: Vec<_> = ptx.input_utxos().iter().flatten().cloned().collect(); - let fees = self.get_fees(&input_utxos, ptx.tx().outputs()).await?; + let fees = self + .get_fees( + ptx.tx().inputs(), + &input_utxos, + ptx.tx().outputs(), + Some(ptx.additional_info()), + ) + .await?; let input_commitments_v0 = ptx.make_sighash_input_commitments(SighashInputCommitmentVersion::V0)?; @@ -1311,7 +1327,9 @@ where }) .collect(); let fees = match self.fetch_utxos(&inputs).await { - Ok(input_utxos) => Some(self.get_fees(&input_utxos, tx.outputs()).await?), + Ok(input_utxos) => { + Some(self.get_fees(tx.inputs(), &input_utxos, tx.outputs(), None).await?) + } Err(_) => None, }; let num_inputs = tx.inputs().len(); @@ -1382,7 +1400,7 @@ where only_transaction: bool, ) -> Result<(TransactionToSign, Balances), ControllerError> { let input_utxos = self.fetch_utxos(&inputs).await?; - let fees = self.get_fees(&input_utxos, &outputs).await?; + let fees = self.get_fees(&[], &input_utxos, &outputs, None).await?; let num_inputs = inputs.len(); let inputs = inputs.into_iter().map(TxInput::Utxo).collect(); @@ -1440,11 +1458,16 @@ where async fn get_fees( &self, - inputs: &[TxOutput], + tx_inputs: &[TxInput], + input_utxos: &[TxOutput], outputs: &[TxOutput], + additional_order_info: Option<&PtxAdditionalInfo>, ) -> Result> { - let mut inputs = self.group_inputs(inputs)?; - let outputs = self.group_outputs(outputs)?; + let mut inputs = self.group_inputs(input_utxos)?; + let mut outputs = self.group_outputs(outputs)?; + + self.add_order_command_amounts(tx_inputs, additional_order_info, &mut inputs, &mut outputs) + .await?; let mut fees = BTreeMap::new(); @@ -1467,6 +1490,131 @@ where into_balances(&self.rpc_client, &self.chain_config, fees).await } + // Credits the values that order account command inputs take from (for FillOrder) or free + // from (for ConcludeOrder) the orders' escrow, mirroring the orders accounting semantics. + // For FillOrder the ask currency amount paid by the filler is added to `output_amounts` + // because it is consumed by the transaction. + async fn add_order_command_amounts( + &self, + tx_inputs: &[TxInput], + additional_order_info: Option<&PtxAdditionalInfo>, + input_amounts: &mut BTreeMap, + output_amounts: &mut BTreeMap, + ) -> Result<(), ControllerError> { + for input in tx_inputs { + match input { + TxInput::AccountCommand(_, command) => match command { + AccountCommand::FillOrder(order_id, fill_amount_in_ask_currency, _) => { + let order_info = + self.resolve_order_info(*order_id, additional_order_info).await?; + let filled_amount = orders_accounting::calculate_filled_amount( + order_info.ask_balance, + order_info.give_balance, + *fill_amount_in_ask_currency, + ) + .ok_or(ControllerError::::WalletError( + WalletError::CalculateOrderFilledAmountFailed(*order_id), + ))?; + + add_amount( + input_amounts, + order_currency(&order_info.initially_given)?, + filled_amount, + ) + .map_err(ControllerError::WalletError)?; + add_amount( + output_amounts, + order_currency(&order_info.initially_asked)?, + *fill_amount_in_ask_currency, + ) + .map_err(ControllerError::WalletError)?; + } + AccountCommand::ConcludeOrder(order_id) => { + let order_info = + self.resolve_order_info(*order_id, additional_order_info).await?; + add_concluded_order_amounts(&order_info, input_amounts) + .map_err(ControllerError::WalletError)?; + } + AccountCommand::MintTokens(..) + | AccountCommand::LockTokenSupply(_) + | AccountCommand::UnmintTokens(_) + | AccountCommand::FreezeToken(..) + | AccountCommand::UnfreezeToken(_) + | AccountCommand::ChangeTokenAuthority(..) + | AccountCommand::ChangeTokenMetadataUri(..) => {} + }, + TxInput::OrderAccountCommand(command) => match command { + OrderAccountCommand::FillOrder(order_id, fill_amount_in_ask_currency) => { + let order_info = + self.resolve_order_info(*order_id, additional_order_info).await?; + let filled_amount = orders_accounting::calculate_filled_amount( + order_info.initially_asked.amount(), + order_info.initially_given.amount(), + *fill_amount_in_ask_currency, + ) + .ok_or(ControllerError::::WalletError( + WalletError::CalculateOrderFilledAmountFailed(*order_id), + ))?; + + add_amount( + input_amounts, + order_currency(&order_info.initially_given)?, + filled_amount, + ) + .map_err(ControllerError::WalletError)?; + add_amount( + output_amounts, + order_currency(&order_info.initially_asked)?, + *fill_amount_in_ask_currency, + ) + .map_err(ControllerError::WalletError)?; + } + OrderAccountCommand::ConcludeOrder(order_id) => { + let order_info = + self.resolve_order_info(*order_id, additional_order_info).await?; + add_concluded_order_amounts(&order_info, input_amounts) + .map_err(ControllerError::WalletError)?; + } + OrderAccountCommand::FreezeOrder(_) => {} + }, + TxInput::Utxo(_) | TxInput::Account(_) => {} + } + } + + Ok(()) + } + + // Order balances are committed to by the transaction's signatures, so the additional info + // embedded in a PartiallySignedTransaction is used when present and the node is only asked + // otherwise (e.g. when a signed transaction is inspected). + async fn resolve_order_info( + &self, + order_id: OrderId, + additional_order_info: Option<&PtxAdditionalInfo>, + ) -> Result> { + if let Some(order_info) = additional_order_info + .and_then(|additional_info| additional_info.get_order_info(&order_id)) + { + return Ok(order_info.clone()); + } + + let order_info = self + .rpc_client + .get_order_info(order_id) + .await + .map_err(ControllerError::NodeCallError)? + .ok_or(ControllerError::::WalletError( + WalletError::OrderInfoMissing(order_id), + ))?; + + Ok(OrderAdditionalInfo { + initially_asked: order_info.initially_asked.into(), + initially_given: order_info.initially_given.into(), + ask_balance: order_info.ask_balance, + give_balance: order_info.give_balance, + }) + } + fn group_outputs( &self, outputs: &[TxOutput], @@ -1712,3 +1860,43 @@ where } } } + +fn add_amount( + amounts: &mut BTreeMap, + currency: Currency, + amount: Amount, +) -> Result<(), WalletError> { + let entry = amounts.entry(currency).or_insert(Amount::ZERO); + *entry = (*entry + amount).ok_or(WalletError::OutputAmountOverflow)?; + Ok(()) +} + +fn order_currency(output_value: &OutputValue) -> Result { + Currency::from_output_value(output_value).ok_or(WalletError::UnsupportedTransactionOutput( + Box::new(TxOutput::Transfer( + output_value.clone(), + Destination::AnyoneCanSpend, + )), + )) +} + +fn add_concluded_order_amounts( + order_info: &OrderAdditionalInfo, + input_amounts: &mut BTreeMap, +) -> Result<(), WalletError> { + add_amount( + input_amounts, + order_currency(&order_info.initially_given)?, + order_info.give_balance, + )?; + + let filled_ask_amount = (order_info.initially_asked.amount() - order_info.ask_balance) + .ok_or(WalletError::OutputAmountOverflow)?; + add_amount( + input_amounts, + order_currency(&order_info.initially_asked)?, + filled_ask_amount, + )?; + + Ok(()) +} diff --git a/wallet/wallet-controller/src/tests/compose_transaction_tests.rs b/wallet/wallet-controller/src/tests/compose_transaction_tests.rs index af6a57040b..d8f68da91a 100644 --- a/wallet/wallet-controller/src/tests/compose_transaction_tests.rs +++ b/wallet/wallet-controller/src/tests/compose_transaction_tests.rs @@ -25,15 +25,17 @@ use chainstate::ChainInfo; use common::{ address::pubkeyhash::PublicKeyHash, chain::{ - Destination, OrderData, Transaction, TxInput, TxOutput, UtxoOutPoint, + ChainConfig, Destination, OrderAccountCommand, OrderData, OrderId, Transaction, TxInput, + TxOutput, UtxoOutPoint, block::timestamp::BlockTimestamp, config::create_regtest, htlc::{HashedTimelockContract, HtlcSecret, HtlcSecretHash}, output_value::OutputValue, + partially_signed_transaction::PartiallySignedTransactionConsistencyCheck, timelock::OutputTimeLock, tokens::{RPCTokenInfo, TokenId}, }, - primitives::{Amount, BlockHeight, Id, Idable}, + primitives::{Amount, BlockHeight, H256, Id, Idable}, }; use node_comm::{mock::ClonableMockNodeInterface, node_traits::MockNodeInterface}; use randomness::RngExt as _; @@ -46,9 +48,14 @@ use wallet::{ wallet_events::WalletEventsNoOp, }; use wallet_types::{ - partially_signed_transaction::PtxAdditionalInfo, wallet_type::WalletControllerMode, + partially_signed_transaction::{ + OrderAdditionalInfo, PartiallySignedTransaction, PtxAdditionalInfo, + }, + wallet_type::WalletControllerMode, }; +use wallet_storage::DefaultBackend; + use crate::{ Controller, helpers::get_referenced_token_ids_from_partially_signed_transaction, @@ -57,6 +64,7 @@ use crate::{ MNEMONIC, assert_fees, create_block_scan_wallet, random_rpc_ft_info_with_id_ticker_decimals, tx_with_outputs, wallet_new_dest, }, + types::TransactionToInspect, }; #[rstest] @@ -274,3 +282,267 @@ async fn general_test(#[case] seed: Seed, #[case] use_htlc_secret: bool) { let actual_token_ids = get_referenced_token_ids_from_partially_signed_transaction(&composed_tx); assert_eq!(actual_token_ids, expected_token_ids); } + +async fn create_controller_for_inspection( + chain_config: &Arc, + token_infos: BTreeMap, + utxos_to_return: BTreeMap, +) -> Controller { + let mut wallet = create_wallet_with_mnemonic(Arc::clone(chain_config), MNEMONIC).await; + + let last_block = create_block_scan_wallet( + chain_config, + &mut wallet, + vec![], + Amount::from_atoms(1000), + Destination::AnyoneCanSpend, + 0, + ) + .await; + + let chain_info_to_return = ChainInfo { + best_block_height: BlockHeight::new(1), + best_block_id: last_block.get_id().into(), + best_block_timestamp: last_block.timestamp(), + median_time: BlockTimestamp::from_int_seconds(0), + is_initial_block_download: false, + }; + + let node_mock = { + let mut node_mock = MockNodeInterface::new(); + + node_mock.expect_is_cold_wallet_node().returning(|| WalletControllerMode::Hot); + + node_mock.expect_get_utxo().returning(move |outpoint| { + Ok(Some( + utxos_to_return + .get(&outpoint) + .unwrap_or_else(|| panic!("unexpected utxo request: {outpoint:?}")) + .clone(), + )) + }); + + node_mock.expect_get_token_info().returning(move |token_id| { + Ok(Some( + token_infos + .get(&token_id) + .unwrap_or_else(|| panic!("unexpected token info request: {token_id:?}")) + .clone(), + )) + }); + + node_mock + .expect_chainstate_info() + .returning(move || Ok(chain_info_to_return.clone())); + + node_mock + .expect_mempool_subscribe_to_events() + .returning(|| Ok(Box::new(futures::stream::empty()))); + + node_mock + }; + + Controller::new( + Arc::clone(chain_config), + ClonableMockNodeInterface::from_mock(node_mock), + RuntimeWallet::Software(wallet), + WalletEventsNoOp, + ) + .await + .unwrap() +} + +// Order conclude inputs free the escrowed balances of an order, so these amounts must be +// credited when the wallet calculates the fee/balances data of a partially signed +// transaction. Otherwise a valid transaction spending purely the freed order funds is +// rejected with "Insufficient UTXO amount". +#[rstest] +#[case(Seed::from_entropy())] +#[trace] +#[tokio::test] +async fn inspect_partially_signed_tx_with_order_conclude_input(#[case] seed: Seed) { + let mut rng = make_seedable_rng(seed); + + let chain_config = Arc::new(create_regtest()); + + let token_id = TokenId::random_using(&mut rng); + let token_num_decimals = rng.random_range(1..20); + let token_ticker = gen_random_alnum_string(&mut rng, 5, 10); + + // The order initially asked for 100 coins, giving 200 tokens in exchange. 30 coins have + // been paid into the order so far, so concluding it frees 70 coins and all 200 tokens. + let order_id = OrderId::new(H256::random_using(&mut rng)); + let order_info = OrderAdditionalInfo { + initially_asked: OutputValue::Coin(Amount::from_atoms(100)), + initially_given: OutputValue::TokenV1(token_id, Amount::from_atoms(200)), + ask_balance: Amount::from_atoms(30), + give_balance: Amount::from_atoms(200), + }; + + let freed_ask_amount = (order_info.initially_asked.amount() - order_info.ask_balance).unwrap(); + let conclude_destination = Destination::PublicKeyHash(PublicKeyHash::random_using(&mut rng)); + + // The freed funds are moved on, keeping 10 units of each currency as the transaction fee. + let outputs = vec![ + TxOutput::Transfer( + OutputValue::Coin((freed_ask_amount - Amount::from_atoms(10)).unwrap()), + conclude_destination.clone(), + ), + TxOutput::Transfer( + OutputValue::TokenV1( + token_id, + (order_info.give_balance - Amount::from_atoms(10)).unwrap(), + ), + conclude_destination, + ), + ]; + + let tx = Transaction::new( + 0, + vec![TxInput::OrderAccountCommand(OrderAccountCommand::ConcludeOrder(order_id))], + outputs, + ) + .unwrap(); + let ptx = PartiallySignedTransaction::new( + tx, + vec![None], + vec![None], + vec![None], + None, + PtxAdditionalInfo::new().with_order_info(order_id, order_info), + PartiallySignedTransactionConsistencyCheck::WithAdditionalInfo, + ) + .unwrap(); + + let token_infos_to_return = BTreeMap::from([( + token_id, + RPCTokenInfo::FungibleToken(random_rpc_ft_info_with_id_ticker_decimals( + token_id, + token_ticker, + token_num_decimals, + &mut rng, + )), + )]); + + let controller = + create_controller_for_inspection(&chain_config, token_infos_to_return, BTreeMap::new()) + .await; + + let inspect_result = controller + .inspect_transaction(TransactionToInspect::Partial(ptx)) + .await + .unwrap(); + + assert_fees( + inspect_result.fees.as_ref().unwrap(), + Amount::from_atoms(10), + &BTreeMap::from([(token_id, Amount::from_atoms(10))]), + &BTreeMap::from([(token_id, token_num_decimals)]), + &chain_config, + ); +} + +// Order fill inputs consume the fill amount in the ask currency from the transaction's UTXO +// inputs and credit the filled amount in the give currency, which must be reflected in the +// fee/balances data of a partially signed transaction. +#[rstest] +#[case(Seed::from_entropy())] +#[trace] +#[tokio::test] +async fn inspect_partially_signed_tx_with_order_fill_input(#[case] seed: Seed) { + let mut rng = make_seedable_rng(seed); + + let chain_config = Arc::new(create_regtest()); + + let token_id = TokenId::random_using(&mut rng); + let token_num_decimals = rng.random_range(1..20); + let token_ticker = gen_random_alnum_string(&mut rng, 5, 10); + + // The order initially asked for 100 coins, giving 200 tokens in exchange, i.e. the price + // is 2 tokens per coin. 30 coins have been filled so far. Filling 30 more coins pays + // 30 coins into the order and returns 60 tokens to the filler. + let order_id = OrderId::new(H256::random_using(&mut rng)); + let order_info = OrderAdditionalInfo { + initially_asked: OutputValue::Coin(Amount::from_atoms(100)), + initially_given: OutputValue::TokenV1(token_id, Amount::from_atoms(200)), + ask_balance: Amount::from_atoms(70), + give_balance: Amount::from_atoms(140), + }; + let fill_amount_in_ask_currency = Amount::from_atoms(30); + + let coins_outpoint = UtxoOutPoint::new(Id::::random_using(&mut rng).into(), 0); + let coins_utxo = TxOutput::Transfer( + OutputValue::Coin(Amount::from_atoms(100)), + Destination::PublicKeyHash(PublicKeyHash::random_using(&mut rng)), + ); + + let token_destination = Destination::PublicKeyHash(PublicKeyHash::random_using(&mut rng)); + let change_destination = Destination::PublicKeyHash(PublicKeyHash::random_using(&mut rng)); + + let outputs = vec![ + TxOutput::Transfer( + OutputValue::TokenV1(token_id, Amount::from_atoms(60)), + token_destination, + ), + TxOutput::Transfer( + OutputValue::Coin(Amount::from_atoms(69)), + change_destination, + ), + ]; + + let tx = Transaction::new( + 0, + vec![ + TxInput::Utxo(coins_outpoint.clone()), + TxInput::OrderAccountCommand(OrderAccountCommand::FillOrder( + order_id, + fill_amount_in_ask_currency, + )), + ], + outputs, + ) + .unwrap(); + let ptx = PartiallySignedTransaction::new( + tx, + vec![None, None], + vec![Some(coins_utxo.clone()), None], + vec![None, None], + None, + PtxAdditionalInfo::new().with_order_info(order_id, order_info), + PartiallySignedTransactionConsistencyCheck::WithAdditionalInfo, + ) + .unwrap(); + + let token_infos_to_return = BTreeMap::from([( + token_id, + RPCTokenInfo::FungibleToken(random_rpc_ft_info_with_id_ticker_decimals( + token_id, + token_ticker, + token_num_decimals, + &mut rng, + )), + )]); + + let controller = create_controller_for_inspection( + &chain_config, + token_infos_to_return, + BTreeMap::from([(coins_outpoint, coins_utxo)]), + ) + .await; + + let inspect_result = controller + .inspect_transaction(TransactionToInspect::Partial(ptx)) + .await + .unwrap(); + + // 100 coins come from the UTXO, 30 of them are consumed by the order fill and 69 are + // transferred on, leaving 1 coin of fee. The 60 tokens received from the order fully + // cover the token output. + assert_fees( + inspect_result.fees.as_ref().unwrap(), + Amount::from_atoms(1), + &BTreeMap::new(), + &BTreeMap::from([(token_id, token_num_decimals)]), + &chain_config, + ); +}