From 227e2574445662abf03bfba7fe9ec0c755f2af4a Mon Sep 17 00:00:00 2001 From: SomberNight Date: Mon, 23 Oct 2023 19:06:42 +0000 Subject: [PATCH] (follow-up) wallet: add option to merge duplicate tx outputs --- electrum/address_synchronizer.py | 2 + electrum/gui/qt/confirm_tx_dialog.py | 6 ++ electrum/gui/qt/transaction_dialog.py | 2 +- electrum/simple_config.py | 6 ++ electrum/tests/test_wallet_vertical.py | 81 +++++++++++++++++++++++++- electrum/transaction.py | 15 +++-- electrum/wallet.py | 5 +- 7 files changed, 107 insertions(+), 10 deletions(-) diff --git a/electrum/address_synchronizer.py b/electrum/address_synchronizer.py index c672f85ff..e21a94abd 100644 --- a/electrum/address_synchronizer.py +++ b/electrum/address_synchronizer.py @@ -416,6 +416,8 @@ class AddressSynchronizer(Logger, EventListener): return children def receive_tx_callback(self, tx_hash: str, tx: Transaction, tx_height: int) -> None: + # TODO ^ don't pass tx_hash, just calculate it. + assert tx_hash == tx.txid(), f"inconsistent txids! given: {tx_hash}, calc: {tx.txid()}" self.add_unverified_or_unconfirmed_tx(tx_hash, tx_height) self.add_transaction(tx, allow_unrelated=True) diff --git a/electrum/gui/qt/confirm_tx_dialog.py b/electrum/gui/qt/confirm_tx_dialog.py index a122e888f..bd7df229b 100644 --- a/electrum/gui/qt/confirm_tx_dialog.py +++ b/electrum/gui/qt/confirm_tx_dialog.py @@ -411,6 +411,7 @@ class TxEditor(WindowModalDialog): ])) self.use_multi_change_menu.setEnabled(self.wallet.use_change) add_cv_action(self.config.cv.WALLET_BATCH_RBF, self.toggle_batch_rbf) + add_cv_action(self.config.cv.WALLET_MERGE_DUPLICATE_OUTPUTS, self.toggle_merge_duplicate_outputs) add_cv_action(self.config.cv.WALLET_SPEND_CONFIRMED_ONLY, self.toggle_confirmed_only) add_cv_action(self.config.cv.WALLET_COIN_CHOOSER_OUTPUT_ROUNDING, self.toggle_output_rounding) self.pref_button = QToolButton() @@ -451,6 +452,11 @@ class TxEditor(WindowModalDialog): self.config.WALLET_BATCH_RBF = b self.trigger_update() + def toggle_merge_duplicate_outputs(self): + b = not self.config.WALLET_MERGE_DUPLICATE_OUTPUTS + self.config.WALLET_MERGE_DUPLICATE_OUTPUTS = b + self.trigger_update() + def toggle_send_change_to_lightning(self): b = not self.config.WALLET_SEND_CHANGE_TO_LIGHTNING self.config.WALLET_SEND_CHANGE_TO_LIGHTNING = b diff --git a/electrum/gui/qt/transaction_dialog.py b/electrum/gui/qt/transaction_dialog.py index 261ce4152..e801c4599 100644 --- a/electrum/gui/qt/transaction_dialog.py +++ b/electrum/gui/qt/transaction_dialog.py @@ -731,7 +731,7 @@ class TxDialog(QDialog, MessageBoxMixin): if not tx: return try: - self.tx.join_with_other_psbt(tx) + self.tx.join_with_other_psbt(tx, config=self.config) except Exception as e: self.show_error(_("Error joining partial transactions") + ":\n" + repr(e)) return diff --git a/electrum/simple_config.py b/electrum/simple_config.py index 83020665b..583cc54b5 100644 --- a/electrum/simple_config.py +++ b/electrum/simple_config.py @@ -939,6 +939,12 @@ class SimpleConfig(Logger): _('If you check this box, your unconfirmed transactions will be consolidated into a single transaction.') + '\n' + _('This will save fees, but might have unwanted effects in terms of privacy')), ) + WALLET_MERGE_DUPLICATE_OUTPUTS = ConfigVar( + 'wallet_merge_duplicate_outputs', default=False, type_=bool, + short_desc=lambda: _('Merge duplicate outputs'), + long_desc=lambda: _('Merge transaction outputs that pay to the same address into ' + 'a single output that pays the sum of the original amounts.'), + ) WALLET_SPEND_CONFIRMED_ONLY = ConfigVar( 'confirmed_only', default=False, type_=bool, short_desc=lambda: _('Spend only confirmed coins'), diff --git a/electrum/tests/test_wallet_vertical.py b/electrum/tests/test_wallet_vertical.py index 6c1406723..c32d0c1e8 100644 --- a/electrum/tests/test_wallet_vertical.py +++ b/electrum/tests/test_wallet_vertical.py @@ -1888,6 +1888,85 @@ class TestWalletSending(ElectrumTestCase): self.assertEqual('02000000000102bbef0182c2c746bd28517b6fd27ba9eef9c7fb5982efd27bd612cc5a28615a3a0000000000fdffffffbbef0182c2c746bd28517b6fd27ba9eef9c7fb5982efd27bd612cc5a28615a3a0100000000fdffffff02602200000000000016001413fabce9be995554a722fc4e1c5ae53ebfd58164905f010000000000160014b266f4f1b9f0bc72f090573d049df66d4efa082c0247304402205c50b9ddb1b3ead6214d7d9707c74ba29ff547880d017aae2459db156bf85b9b022041134562fffa3dccf1ac05d9b07da62a8d57dd158d25d22d1965a011325e64aa012102c72b815ba00ccb0b469cc61a0ceb843d974e630cf34abcfac178838f1974f68f02473044022049774c32b0ad046b7acdb4acc38107b6b1be57c0d167643a48cbc045850c86c202205189ed61342fc52a377c2865a879c4c2606de98eebd6bf4d73874d62329668c70121033484c8ed83c359d1c3e569accb04b77988daab9408fc82869051c10d0749ac2006fa2400', str(tx)) + async def test_rbf_batching__merge_duplicate_outputs(self): + """txos paying to the same address might be merged into a single output with a larger value""" + wallet = self.create_standard_wallet_from_seed('response era cable net spike again observe dumb wage wonder sail tortoise', + config=self.config) + wallet.config.WALLET_BATCH_RBF = True + + # bootstrap wallet (incoming funding_tx0): for 500k sat + funding_tx = Transaction('02000000000101013548c9019890e27ce9e58766de05f18ea40ede70751fb6cd7a3a1715ece0a30100000000fdffffff0220a1070000000000160014542266519a44eb9b903761d40c6fe1055d33fa05485a080000000000160014bc69f7d82c403a9f35dfb6d1a4531d6b19cab0e3024730440220346b200f21c3024e1d51fb4ecddbdbd68bd24ae7b9dfd501519f6dcbeb7c052402200617e3ce7b0eb308e30caf23894fb0388b68fb1c15dd0681dd13ae5e735f148101210360d0c9ef15b8b6a16912d341ad218a4e4e4e07e9347f4a2dbc7ca8d974f8bc9ec1ad2600') + funding_txid = funding_tx.txid() + wallet.adb.receive_tx_callback(funding_txid, funding_tx, TX_HEIGHT_UNCONFIRMED) + + dest_addr = "tb1qtzhwpufqr5dwztdaysfqnwlf9m29uwdkq8zm9w" + # first payment to dest_addr + outputs1 = [PartialTxOutput.from_address_and_value(dest_addr, 200_000)] + coins = wallet.get_spendable_coins(domain=None) + tx1 = wallet.make_unsigned_transaction(coins=coins, outputs=outputs1, fee=2000) + tx1.set_rbf(True) + tx1.locktime = 2534850 + tx1.version = 2 + wallet.sign_transaction(tx1, password=None) + self.assertEqual(2, len(tx1.outputs())) + self.assertEqual('020000000001019264597cffcce8f0c17b16a02adca7a95ae90f2ea51bd4b4df60c76dfe86686e0000000000fdffffff02400d03000000000016001458aee0f1201d1ae12dbd241209bbe92ed45e39b6108c0400000000001600144e1b662f616fe134430054e29295ea6e5c18f1730247304402205ea932303bb89bfe07c1e4c28117cb84f613e09dd51464aa2ed2b184c2f2b76902202968280003b0e7d4098bf9adc47246db7b84c83f718e70a609de05f3b2ae64e80121029b1a61d66896486ab893741b38dbafb9673b91a82237d6e4ca0da3cda7cbeb7cc2ad2600', + str(tx1)) + wallet.adb.receive_tx_callback(tx1.txid(), tx1, TX_HEIGHT_UNCONFIRMED) + self.assertEqual((0, 298_000, 0), wallet.get_balance()) + + wallet.config.WALLET_MERGE_DUPLICATE_OUTPUTS = True + # second payment to dest_addr (merged) + outputs2 = [PartialTxOutput.from_address_and_value(dest_addr, 100_000)] + coins = wallet.get_spendable_coins(domain=None) + tx2 = wallet.make_unsigned_transaction(coins=coins, outputs=outputs2, fee=3000) + tx2.set_rbf(True) + tx2.locktime = 2534850 + tx2.version = 2 + wallet.sign_transaction(tx2, password=None) + self.assertEqual(2, len(tx2.outputs())) + self.assertEqual('020000000001019264597cffcce8f0c17b16a02adca7a95ae90f2ea51bd4b4df60c76dfe86686e0000000000fdffffff0288010300000000001600144e1b662f616fe134430054e29295ea6e5c18f173e09304000000000016001458aee0f1201d1ae12dbd241209bbe92ed45e39b60247304402201b5856f572a70f667392f000780044a6c6677eadadd5b56d2b15d1f90a8bf4b7022046566836d7e1e1a099ff72b4ecb09d6b24e701e12c0fb4c5667172d47d9b54520121029b1a61d66896486ab893741b38dbafb9673b91a82237d6e4ca0da3cda7cbeb7cc2ad2600', + str(tx2)) + wallet.adb.receive_tx_callback(tx2.txid(), tx2, TX_HEIGHT_UNCONFIRMED) + self.assertEqual((0, 197_000, 0), wallet.get_balance()) + + # remove tx2 from wallet, by replacing it with tx1 + wallet.adb.receive_tx_callback(tx1.txid(), tx1, TX_HEIGHT_UNCONFIRMED) + self.assertEqual((0, 298_000, 0), wallet.get_balance()) + + wallet.config.WALLET_MERGE_DUPLICATE_OUTPUTS = False + # second payment to dest_addr (not merged, just duplicate outputs) + outputs2 = [PartialTxOutput.from_address_and_value(dest_addr, 100_000)] + coins = wallet.get_spendable_coins(domain=None) + tx3 = wallet.make_unsigned_transaction(coins=coins, outputs=outputs2, fee=3000) + tx3.set_rbf(True) + tx3.locktime = 2534850 + tx3.version = 2 + wallet.sign_transaction(tx3, password=None) + self.assertEqual(3, len(tx3.outputs())) + self.assertEqual('020000000001019264597cffcce8f0c17b16a02adca7a95ae90f2ea51bd4b4df60c76dfe86686e0000000000fdffffff03a08601000000000016001458aee0f1201d1ae12dbd241209bbe92ed45e39b688010300000000001600144e1b662f616fe134430054e29295ea6e5c18f173400d03000000000016001458aee0f1201d1ae12dbd241209bbe92ed45e39b602473044022061386129ebefda19e22ab9e2c06642a2a5eb7637e1b492d5c164591ff0fb27c9022006129d5d0c780d6830fb6cf924e3eeef03b8a349a9ebb36969cae410d9ff0fa50121029b1a61d66896486ab893741b38dbafb9673b91a82237d6e4ca0da3cda7cbeb7cc2ad2600', + str(tx3)) + wallet.adb.receive_tx_callback(tx3.txid(), tx3, TX_HEIGHT_UNCONFIRMED) + self.assertEqual((0, 197_000, 0), wallet.get_balance()) + + async def test_join_psbts__merge_duplicate_outputs(self): + """txos paying to the same address might be merged into a single output with a larger value""" + rawtx1 = "70736274ff01007102000000019264597cffcce8f0c17b16a02adca7a95ae90f2ea51bd4b4df60c76dfe86686e0000000000fdffffff02400d03000000000016001458aee0f1201d1ae12dbd241209bbe92ed45e39b6108c0400000000001600144e1b662f616fe134430054e29295ea6e5c18f173c2ad26000001011f20a1070000000000160014542266519a44eb9b903761d40c6fe1055d33fa050100de02000000000101013548c9019890e27ce9e58766de05f18ea40ede70751fb6cd7a3a1715ece0a30100000000fdffffff0220a1070000000000160014542266519a44eb9b903761d40c6fe1055d33fa05485a080000000000160014bc69f7d82c403a9f35dfb6d1a4531d6b19cab0e3024730440220346b200f21c3024e1d51fb4ecddbdbd68bd24ae7b9dfd501519f6dcbeb7c052402200617e3ce7b0eb308e30caf23894fb0388b68fb1c15dd0681dd13ae5e735f148101210360d0c9ef15b8b6a16912d341ad218a4e4e4e07e9347f4a2dbc7ca8d974f8bc9ec1ad26002206029b1a61d66896486ab893741b38dbafb9673b91a82237d6e4ca0da3cda7cbeb7c101f1b48320000008000000000000000000000220203db4846ec1841f48484590e67fcd7d1039f124a04410c5794f38ec8625329ea23101f1b483200000080010000000000000000" + rawtx2 = "70736274ff0100710200000001a4c6da70097e1bfbbcba0edad4ba1143295300b60851aa6c4916a0b32381bf7f0000000000fdffffff02a08601000000000016001458aee0f1201d1ae12dbd241209bbe92ed45e39b6108c040000000000160014fac4435311276a6cfda5681cfb02252acdd14c3fc2ad26000001011f801a06000000000016001452af44a1e32754fd8d2e7c1c3cc1b305379f0b660100de020000000001018eeaf0cd7de0e0e117af1a7f2bab59b4ddfbd416ef7460b3fd42a1f7bc039cfd0000000000fdffffff02801a06000000000016001452af44a1e32754fd8d2e7c1c3cc1b305379f0b66909f0700000000001600140847a3685a3ce9911cdce3fbf33cb42edc8f6dd902473044022044d3485c09784f03cd648117ef2d4d0dabeeb2929b30f2e52c3bbd5efd1c0f820220346655235eb9fcb54b23bbf194217092cc8aa6dd33ecf018907626b90289be6801210304e06afd290a4e7a9eb008cf408a4f9b0640fd2688258b523aa3dbb236bb3f7eccad2600220602c1ed648e71f15643950b444b864ab784b9d0e31e6ca6ec7d849d3dda4d98da05101f1b48320000008000000000010000000000220203aba60233db3aab45d0196cb70a22d667faa92124760700d20c953b0222ced96d101f1b483200000080010000000100000000" + + self.config.WALLET_MERGE_DUPLICATE_OUTPUTS = False + joined_tx = tx_from_any(rawtx1) + joined_tx.join_with_other_psbt(tx_from_any(rawtx2), config=self.config) + self.assertEqual(4, len(joined_tx.outputs())) + self.assertEqual("70736274ff0100d802000000029264597cffcce8f0c17b16a02adca7a95ae90f2ea51bd4b4df60c76dfe86686e0000000000fdffffffa4c6da70097e1bfbbcba0edad4ba1143295300b60851aa6c4916a0b32381bf7f0000000000fdffffff04a08601000000000016001458aee0f1201d1ae12dbd241209bbe92ed45e39b6400d03000000000016001458aee0f1201d1ae12dbd241209bbe92ed45e39b6108c0400000000001600144e1b662f616fe134430054e29295ea6e5c18f173108c040000000000160014fac4435311276a6cfda5681cfb02252acdd14c3fc2ad26000001011f20a1070000000000160014542266519a44eb9b903761d40c6fe1055d33fa050100de02000000000101013548c9019890e27ce9e58766de05f18ea40ede70751fb6cd7a3a1715ece0a30100000000fdffffff0220a1070000000000160014542266519a44eb9b903761d40c6fe1055d33fa05485a080000000000160014bc69f7d82c403a9f35dfb6d1a4531d6b19cab0e3024730440220346b200f21c3024e1d51fb4ecddbdbd68bd24ae7b9dfd501519f6dcbeb7c052402200617e3ce7b0eb308e30caf23894fb0388b68fb1c15dd0681dd13ae5e735f148101210360d0c9ef15b8b6a16912d341ad218a4e4e4e07e9347f4a2dbc7ca8d974f8bc9ec1ad26002206029b1a61d66896486ab893741b38dbafb9673b91a82237d6e4ca0da3cda7cbeb7c101f1b48320000008000000000000000000001011f801a06000000000016001452af44a1e32754fd8d2e7c1c3cc1b305379f0b660100de020000000001018eeaf0cd7de0e0e117af1a7f2bab59b4ddfbd416ef7460b3fd42a1f7bc039cfd0000000000fdffffff02801a06000000000016001452af44a1e32754fd8d2e7c1c3cc1b305379f0b66909f0700000000001600140847a3685a3ce9911cdce3fbf33cb42edc8f6dd902473044022044d3485c09784f03cd648117ef2d4d0dabeeb2929b30f2e52c3bbd5efd1c0f820220346655235eb9fcb54b23bbf194217092cc8aa6dd33ecf018907626b90289be6801210304e06afd290a4e7a9eb008cf408a4f9b0640fd2688258b523aa3dbb236bb3f7eccad2600220602c1ed648e71f15643950b444b864ab784b9d0e31e6ca6ec7d849d3dda4d98da05101f1b4832000000800000000001000000000000220203db4846ec1841f48484590e67fcd7d1039f124a04410c5794f38ec8625329ea23101f1b483200000080010000000000000000220203aba60233db3aab45d0196cb70a22d667faa92124760700d20c953b0222ced96d101f1b483200000080010000000100000000", + joined_tx.serialize_as_bytes().hex()) + + self.config.WALLET_MERGE_DUPLICATE_OUTPUTS = True + joined_tx = tx_from_any(rawtx1) + joined_tx.join_with_other_psbt(tx_from_any(rawtx2), config=self.config) + self.assertEqual(3, len(joined_tx.outputs())) + self.assertEqual("70736274ff0100b902000000029264597cffcce8f0c17b16a02adca7a95ae90f2ea51bd4b4df60c76dfe86686e0000000000fdffffffa4c6da70097e1bfbbcba0edad4ba1143295300b60851aa6c4916a0b32381bf7f0000000000fdffffff03108c0400000000001600144e1b662f616fe134430054e29295ea6e5c18f173108c040000000000160014fac4435311276a6cfda5681cfb02252acdd14c3fe09304000000000016001458aee0f1201d1ae12dbd241209bbe92ed45e39b6c2ad26000001011f20a1070000000000160014542266519a44eb9b903761d40c6fe1055d33fa050100de02000000000101013548c9019890e27ce9e58766de05f18ea40ede70751fb6cd7a3a1715ece0a30100000000fdffffff0220a1070000000000160014542266519a44eb9b903761d40c6fe1055d33fa05485a080000000000160014bc69f7d82c403a9f35dfb6d1a4531d6b19cab0e3024730440220346b200f21c3024e1d51fb4ecddbdbd68bd24ae7b9dfd501519f6dcbeb7c052402200617e3ce7b0eb308e30caf23894fb0388b68fb1c15dd0681dd13ae5e735f148101210360d0c9ef15b8b6a16912d341ad218a4e4e4e07e9347f4a2dbc7ca8d974f8bc9ec1ad26002206029b1a61d66896486ab893741b38dbafb9673b91a82237d6e4ca0da3cda7cbeb7c101f1b48320000008000000000000000000001011f801a06000000000016001452af44a1e32754fd8d2e7c1c3cc1b305379f0b660100de020000000001018eeaf0cd7de0e0e117af1a7f2bab59b4ddfbd416ef7460b3fd42a1f7bc039cfd0000000000fdffffff02801a06000000000016001452af44a1e32754fd8d2e7c1c3cc1b305379f0b66909f0700000000001600140847a3685a3ce9911cdce3fbf33cb42edc8f6dd902473044022044d3485c09784f03cd648117ef2d4d0dabeeb2929b30f2e52c3bbd5efd1c0f820220346655235eb9fcb54b23bbf194217092cc8aa6dd33ecf018907626b90289be6801210304e06afd290a4e7a9eb008cf408a4f9b0640fd2688258b523aa3dbb236bb3f7eccad2600220602c1ed648e71f15643950b444b864ab784b9d0e31e6ca6ec7d849d3dda4d98da05101f1b483200000080000000000100000000220203db4846ec1841f48484590e67fcd7d1039f124a04410c5794f38ec8625329ea23101f1b483200000080010000000000000000220203aba60233db3aab45d0196cb70a22d667faa92124760700d20c953b0222ced96d101f1b48320000008001000000010000000000", + joined_tx.serialize_as_bytes().hex()) + @mock.patch.object(wallet.Abstract_Wallet, 'save_db') async def test_cpfp_p2wpkh(self, mock_save_db): wallet = self.create_standard_wallet_from_seed('frost repair depend effort salon ring foam oak cancel receive save usage') @@ -2117,7 +2196,7 @@ class TestWalletSending(ElectrumTestCase): partial_tx2) # wallet2 gets raw partial tx1, merges it into his own tx2 - tx2.join_with_other_psbt(tx_from_any(partial_tx1)) + tx2.join_with_other_psbt(tx_from_any(partial_tx1), config=self.config) partial_tx2 = tx2.serialize_as_bytes().hex() self.assertEqual("70736274ff0100d80200000002e546bc0a7c9736e82a07df5c24fe6d05df58a310dc376cf09302842ca7264f930100000000fdffffffd5bd4f8ebe63f0521f94e2d174b95d4327757a7e74fda3c9ff5c08796318f8d80000000000fdffffff04988d07000000000016001453675a59be834aa6d139c3ebea56646a9b160c4cb82e0f0000000000160014250dbabd5761d7e0773d6147699938dd08ec2eb88096980000000000160014b93357242ad5a6fff8930ce9dadd8ba44a6c44498096980000000000160014e2672a59431c261903c9469aa082202f37a859a46f8518000001011fa037a000000000001600140719d12228c61cab793ecd659c09cfe565a845c30100df02000000000101d5bd4f8ebe63f0521f94e2d174b95d4327757a7e74fda3c9ff5c08796318f8d80100000000fdffffff025066350000000000160014e3aa82aa2e754507d5585c0b6db06cc0cb4927b7a037a000000000001600140719d12228c61cab793ecd659c09cfe565a845c302483045022100f42e27519bd2379c22951c16b038fa6d49164fe6802854f2fdc7ee87fe31a8bc02204ea71e9324781b44bf7fea2f318caf3bedc5b497cbd1b4313fa71f833500bcb7012103a7853e1ee02a1629c8e870ec694a1420aeb98e6f5d071815257028f62d6f784169851800220602275b4fba18bb34e5198a9cfb3e940306658839079b3bda50d504a9cf2bae36f41067f366970000008000000000010000000001011fc0d8a70000000000160014aba1c9faecc3f8882e641583e8734a3f9d01b15a0100df0200000000010162ecbac2f0c8662f53505d9410fdc56c84c5642ddbd3358d9a27d564e26731130200000000fdffffff02c0d8a70000000000160014aba1c9faecc3f8882e641583e8734a3f9d01b15ab89ed5000000000016001470afbd97b2dc351bd167f714e294b2fd3b60aedf02483045022100c93449989510e279eb14a0193d5c262ae93034b81376a1f6be259c6080d3ba5d0220536ab394f7c20f301d7ec2ef11be6e7b6d492053dce56458931c1b54218ec0fd012103b8f5a11df8e68cf335848e83a41fdad3c7413dc42148248a3799b58c93919ca010851800002202036e4d0a5fb845b2f1c3c868c2ce7212b155b73e91c05be1b7a77c48830831ba4f1067f3669700000080010000000000000000000022020200062fdea2b0a056b17fa6b91dd87f5b5d838fe1ee84d636a5022f9a340eebcc1067f3669700000080000000000000000000", partial_tx2) diff --git a/electrum/transaction.py b/electrum/transaction.py index d3c3a58e8..1e18fca38 100644 --- a/electrum/transaction.py +++ b/electrum/transaction.py @@ -59,6 +59,7 @@ from .json_db import stored_in if TYPE_CHECKING: from .wallet import Abstract_Wallet from .network import Network + from .simple_config import SimpleConfig _logger = get_logger(__name__) @@ -613,14 +614,15 @@ def check_scriptpubkey_template_and_dust(scriptpubkey, amount: Optional[int]): if amount < dust_limit: raise Exception(f'amount ({amount}) is below dust limit for scriptpubkey type ({dust_limit})') -def merge_tx_outputs(outputs): +def merge_duplicate_tx_outputs(outputs: Iterable['PartialTxOutput']) -> List['PartialTxOutput']: + """Merges outputs that are paying to the same address by replacing them with a single larger output.""" output_dict = {} for output in outputs: + assert isinstance(output.value, int), "tx outputs with spend-max-like str cannot be merged" if output.scriptpubkey in output_dict: output_dict[output.scriptpubkey].value += output.value else: output_dict[output.scriptpubkey] = copy.copy(output) - return list(output_dict.values()) def match_script_against_template(script, template, debug=False) -> bool: @@ -2017,7 +2019,7 @@ class PartialTransaction(Transaction): txout.combine_with_other_txout(other_txout) self.invalidate_ser_cache() - def join_with_other_psbt(self, other_tx: 'PartialTransaction') -> None: + def join_with_other_psbt(self, other_tx: 'PartialTransaction', *, config: 'SimpleConfig') -> None: """Adds inputs and outputs from other_tx into this one.""" if not isinstance(other_tx, PartialTransaction): raise Exception('Can only join partial transactions.') @@ -2034,7 +2036,7 @@ class PartialTransaction(Transaction): self._unknown.update(other_tx._unknown) # copy and add inputs and outputs self.add_inputs(list(other_tx.inputs())) - self.add_outputs(list(other_tx.outputs())) + self.add_outputs(list(other_tx.outputs()), merge_duplicates=config.WALLET_MERGE_DUPLICATE_OUTPUTS) self.remove_signatures() self.invalidate_ser_cache() @@ -2049,9 +2051,10 @@ class PartialTransaction(Transaction): self.BIP69_sort(outputs=False) self.invalidate_ser_cache() - def add_outputs(self, outputs: List[PartialTxOutput]) -> None: + def add_outputs(self, outputs: List[PartialTxOutput], *, merge_duplicates: bool = False) -> None: self._outputs.extend(outputs) - self._outputs = merge_tx_outputs(self._outputs) + if merge_duplicates: + self._outputs = merge_duplicate_tx_outputs(self._outputs) self.BIP69_sort(inputs=False) self.invalidate_ser_cache() diff --git a/electrum/wallet.py b/electrum/wallet.py index 746799a27..68642a095 100644 --- a/electrum/wallet.py +++ b/electrum/wallet.py @@ -1742,7 +1742,7 @@ class Abstract_Wallet(ABC, Logger, EventListener): # prevent side-effect with '!' outputs = copy.deepcopy(outputs) - # check outputs + # check outputs for "max" amount i_max = [] i_max_sum = 0 for i, o in enumerate(outputs): @@ -1793,7 +1793,6 @@ class Abstract_Wallet(ABC, Logger, EventListener): return max(lower_bound, original_fee_estimator(size)) txi = base_tx.inputs() txo = list(filter(lambda o: not self.is_change(o.address), base_tx.outputs())) + list(outputs) - txo = transaction.merge_tx_outputs(txo) old_change_addrs = [o.address for o in base_tx.outputs() if self.is_change(o.address)] rbf_merge_txid = base_tx.txid() else: @@ -1802,6 +1801,8 @@ class Abstract_Wallet(ABC, Logger, EventListener): old_change_addrs = [] # change address. if empty, coin_chooser will set it change_addrs = self.get_change_addresses_for_new_transaction(change_addr or old_change_addrs) + if self.config.WALLET_MERGE_DUPLICATE_OUTPUTS: + txo = transaction.merge_duplicate_tx_outputs(txo) tx = coin_chooser.make_tx( coins=coins, inputs=txi,