diff --git a/electrum/tests/test_wallet_vertical.py b/electrum/tests/test_wallet_vertical.py index bdf4b4647..120fc9a90 100644 --- a/electrum/tests/test_wallet_vertical.py +++ b/electrum/tests/test_wallet_vertical.py @@ -2197,6 +2197,123 @@ class TestWalletSending(TestCaseForTestnet): self.assertEqual(1, len(tx.inputs())) self.assertEqual(2, len(tx.outputs())) + @mock.patch.object(wallet.Abstract_Wallet, 'save_db') + def test_imported_wallet_usechange_off(self, mock_save_db): + wallet = restore_wallet_from_text( + "p2wpkh:cVcwSp488C8Riguq55Tuktgi6TpzuyLdDwUxkBDBz3yzV7FW4af2 p2wpkh:cPWyoPvnv2hiyyxbhMkhX3gPEENzB6DqoP9bbR8SDTg5njK5SL9n", + path='if_this_exists_mocking_failed_648151893', + config=self.config)['wallet'] # type: Abstract_Wallet + + # bootstrap wallet + funding_tx = Transaction('02000000000101c6edaaf0157020a38de8b07810b22ffe331d5b79c83b680dad24da15c572ae7d0000000000fdffffff026080010000000000160014eabbd791df76eeeaa3ed273cac4e1dde3be295cca0860100000000001600147a65e09bb1da80abfc65d545388a2e61aab7c7210247304402203cb8b2f84ed4fb8de5f51a07b2159bc0d8d474e5dba0f77cc66ab641cf48621b022076fb3c6b4bc76aa06dd29ebe1dd081c063cdbd2949ffcf4ab4bd8bddae6c948b0121029f16b602a6b3c738b66a03dd5133abe810169a377bbc2fdf5c5363f59b8d9bdec3951e00') + funding_txid = funding_tx.txid() + self.assertEqual('9bed2a210b4154183295bc7b78c8841a3a6116197713f744e5cd95ab0c0c01ce', funding_txid) + wallet.receive_tx_callback(funding_txid, funding_tx, TX_HEIGHT_UNCONFIRMED) + + # imported wallets do not send change to change addresses by default + # (they send it back to the "from address") + self.assertFalse(wallet.use_change) + + outputs = [PartialTxOutput.from_address_and_value('tb1qq4pypzwxf5uanfyckmsu3ejxxf6rrvjqchza3v', 49646)] + coins = wallet.get_spendable_coins(domain=None) + tx = wallet.make_unsigned_transaction(coins=coins, outputs=outputs, fee=1000) + tx.set_rbf(True) + tx.locktime = 2004420 + tx.version = 2 + + # check that change is sent back to the "from address" + self.assertEqual(2, len(tx.outputs())) + self.assertTrue(tx.output_value_for_address("tb1q0fj7pxa3m2q2hlr964zn3z3wvx4t03ep5fgnhy") > 0) + self.assertEqual(49646, tx.output_value_for_address("tb1qq4pypzwxf5uanfyckmsu3ejxxf6rrvjqchza3v")) + + self.assertEqual("70736274ff0100710200000001ce010c0cab95cde544f713771916613a1a84c8787bbc95321854410b212aed9b0100000000fdffffff02cac00000000000001600147a65e09bb1da80abfc65d545388a2e61aab7c721eec100000000000016001405424089c64d39d9a498b6e1c8e646327431b240c4951e00000100de02000000000101c6edaaf0157020a38de8b07810b22ffe331d5b79c83b680dad24da15c572ae7d0000000000fdffffff026080010000000000160014eabbd791df76eeeaa3ed273cac4e1dde3be295cca0860100000000001600147a65e09bb1da80abfc65d545388a2e61aab7c7210247304402203cb8b2f84ed4fb8de5f51a07b2159bc0d8d474e5dba0f77cc66ab641cf48621b022076fb3c6b4bc76aa06dd29ebe1dd081c063cdbd2949ffcf4ab4bd8bddae6c948b0121029f16b602a6b3c738b66a03dd5133abe810169a377bbc2fdf5c5363f59b8d9bdec3951e00000000", + tx.serialize_as_bytes().hex()) + wallet.sign_transaction(tx, password=None) + tx_copy = tx_from_any(tx.serialize()) + self.assertEqual('02000000000101ce010c0cab95cde544f713771916613a1a84c8787bbc95321854410b212aed9b0100000000fdffffff02cac00000000000001600147a65e09bb1da80abfc65d545388a2e61aab7c721eec100000000000016001405424089c64d39d9a498b6e1c8e646327431b240024730440220526eac6c56cba19842b67f6c9e45af113b1a2d44fb229335bdeaf08cb2cc164e0220087fba65619016fd3f62f6c8717070e48f94b45743b86d8e0517698d2b9c3afc012102d67eaa10463f5c786271feb9ae3456c27d35c3cf6c7d881617e915d1f32cb875c4951e00', + str(tx_copy)) + + @mock.patch.object(wallet.Abstract_Wallet, 'save_db') + def test_imported_wallet_usechange_on(self, mock_save_db): + wallet = restore_wallet_from_text( + "p2wpkh:cVcwSp488C8Riguq55Tuktgi6TpzuyLdDwUxkBDBz3yzV7FW4af2 p2wpkh:cPWyoPvnv2hiyyxbhMkhX3gPEENzB6DqoP9bbR8SDTg5njK5SL9n", + path='if_this_exists_mocking_failed_648151893', + config=self.config)['wallet'] # type: Abstract_Wallet + + # bootstrap wallet + funding_tx = Transaction('02000000000101c6edaaf0157020a38de8b07810b22ffe331d5b79c83b680dad24da15c572ae7d0000000000fdffffff026080010000000000160014eabbd791df76eeeaa3ed273cac4e1dde3be295cca0860100000000001600147a65e09bb1da80abfc65d545388a2e61aab7c7210247304402203cb8b2f84ed4fb8de5f51a07b2159bc0d8d474e5dba0f77cc66ab641cf48621b022076fb3c6b4bc76aa06dd29ebe1dd081c063cdbd2949ffcf4ab4bd8bddae6c948b0121029f16b602a6b3c738b66a03dd5133abe810169a377bbc2fdf5c5363f59b8d9bdec3951e00') + funding_txid = funding_tx.txid() + self.assertEqual('9bed2a210b4154183295bc7b78c8841a3a6116197713f744e5cd95ab0c0c01ce', funding_txid) + wallet.receive_tx_callback(funding_txid, funding_tx, TX_HEIGHT_UNCONFIRMED) + + # instead of sending the change back to the "from address", we want it sent to another unused address + wallet.use_change = True + + outputs = [PartialTxOutput.from_address_and_value('tb1qq4pypzwxf5uanfyckmsu3ejxxf6rrvjqchza3v', 49646)] + coins = wallet.get_spendable_coins(domain=None) + tx = wallet.make_unsigned_transaction(coins=coins, outputs=outputs, fee=1000) + tx.set_rbf(True) + tx.locktime = 2004420 + tx.version = 2 + + # check that change is sent to another unused imported address + self.assertEqual(2, len(tx.outputs())) + self.assertTrue(tx.output_value_for_address("tb1qetcgdwuzlpdnt5fmzxxdpczjhadz06cynpttpv") > 0) + self.assertEqual(49646, tx.output_value_for_address("tb1qq4pypzwxf5uanfyckmsu3ejxxf6rrvjqchza3v")) + + self.assertEqual("70736274ff0100710200000001ce010c0cab95cde544f713771916613a1a84c8787bbc95321854410b212aed9b0100000000fdffffff02cac0000000000000160014caf086bb82f85b35d13b118cd0e052bf5a27eb04eec100000000000016001405424089c64d39d9a498b6e1c8e646327431b240c4951e00000100de02000000000101c6edaaf0157020a38de8b07810b22ffe331d5b79c83b680dad24da15c572ae7d0000000000fdffffff026080010000000000160014eabbd791df76eeeaa3ed273cac4e1dde3be295cca0860100000000001600147a65e09bb1da80abfc65d545388a2e61aab7c7210247304402203cb8b2f84ed4fb8de5f51a07b2159bc0d8d474e5dba0f77cc66ab641cf48621b022076fb3c6b4bc76aa06dd29ebe1dd081c063cdbd2949ffcf4ab4bd8bddae6c948b0121029f16b602a6b3c738b66a03dd5133abe810169a377bbc2fdf5c5363f59b8d9bdec3951e00000000", + tx.serialize_as_bytes().hex()) + wallet.sign_transaction(tx, password=None) + tx_copy = tx_from_any(tx.serialize()) + self.assertEqual('02000000000101ce010c0cab95cde544f713771916613a1a84c8787bbc95321854410b212aed9b0100000000fdffffff02cac0000000000000160014caf086bb82f85b35d13b118cd0e052bf5a27eb04eec100000000000016001405424089c64d39d9a498b6e1c8e646327431b24002473044022006dfe30f851b0174e5c920fd5b2e294a25fe5d449b17b422f3fda485d514c39b022047a6760f9d6ddfac5273094bed1f640fc1622a42938ebfb0b5f61cce7b161a00012102d67eaa10463f5c786271feb9ae3456c27d35c3cf6c7d881617e915d1f32cb875c4951e00', + str(tx_copy)) + + @mock.patch.object(wallet.Abstract_Wallet, 'save_db') + def test_imported_wallet_usechange_on__no_more_unused_addresses(self, mock_save_db): + wallet = restore_wallet_from_text( + "p2wpkh:cVcwSp488C8Riguq55Tuktgi6TpzuyLdDwUxkBDBz3yzV7FW4af2 p2wpkh:cPWyoPvnv2hiyyxbhMkhX3gPEENzB6DqoP9bbR8SDTg5njK5SL9n", + path='if_this_exists_mocking_failed_648151893', + config=self.config)['wallet'] # type: Abstract_Wallet + + # bootstrap wallet + funding_tx = Transaction('02000000000101c6edaaf0157020a38de8b07810b22ffe331d5b79c83b680dad24da15c572ae7d0000000000fdffffff026080010000000000160014eabbd791df76eeeaa3ed273cac4e1dde3be295cca0860100000000001600147a65e09bb1da80abfc65d545388a2e61aab7c7210247304402203cb8b2f84ed4fb8de5f51a07b2159bc0d8d474e5dba0f77cc66ab641cf48621b022076fb3c6b4bc76aa06dd29ebe1dd081c063cdbd2949ffcf4ab4bd8bddae6c948b0121029f16b602a6b3c738b66a03dd5133abe810169a377bbc2fdf5c5363f59b8d9bdec3951e00') + funding_txid = funding_tx.txid() + self.assertEqual('9bed2a210b4154183295bc7b78c8841a3a6116197713f744e5cd95ab0c0c01ce', funding_txid) + wallet.receive_tx_callback(funding_txid, funding_tx, TX_HEIGHT_UNCONFIRMED) + # add more txs so that all addresses become used + _txs = [ + ("077c8f7a3b0cbb660192c3e35d01a65694f7b90b10e4c6434713912c44cdbfb7", "02000000000101bc125beec2014e3b89679207116e28bcf5bf85cab63ac2903119c8c21ab84cac0100000000fdffffff02daff000000000000160014caf086bb82f85b35d13b118cd0e052bf5a27eb04814201000000000016001491145275b4c4a4814b733fbd28f2a519a5874bad02473044022008ae14e4f7802639a34e92348db7eef95c9fb5d480d7a110d4b11e7d0c45a0cc02205d29414eebcdc76a07f5e2422ed3e560cd663de4b733a0f9c7b3ad7102a733510121030438b8bdbe8121b6a6508e54247b9d1b0547d9ac94c4d3154afd7d7376fe7ae6b6951e00"), + ("5f8e17612ad4e04819f1b1cf9039509518e230db07140b2eec81582a8647f8d6", "02000000000101b7bfcd442c91134743c6e4100bb9f79456a6015de3c3920166bb0c3b7a8f7c070000000000fdffffff016cff0000000000001600146a84f3681e545d13fa41de090b6e404401198e7d0247304402204e16704d836cb6e1fffa34244c42578267853e8c3933a3d367bd6a236c24596a0220025a7be9483eeba06a433b96b5cb35a6a4b117ffa884569b09cedc4a5f3d6381012103c19caa2ced1b74bf31ba7885d83eeda35c0011e740273ebdf6750e0298588cc5c7951e00"), + ] + for txid, rawtx in _txs: + tx = Transaction(rawtx) + self.assertEqual(txid, tx.txid()) + wallet.receive_tx_callback(txid, tx, TX_HEIGHT_UNCONFIRMED) + + # instead of sending the change back to the "from address", we want it sent to another unused address. + # (except all our addresses are used! so we expect change sent back to "from address") + wallet.use_change = True + + outputs = [PartialTxOutput.from_address_and_value('tb1qq4pypzwxf5uanfyckmsu3ejxxf6rrvjqchza3v', 49646)] + coins = wallet.get_spendable_coins(domain=None) + tx = wallet.make_unsigned_transaction(coins=coins, outputs=outputs, fee=1000) + tx.set_rbf(True) + tx.locktime = 2004420 + tx.version = 2 + + # check that change is sent back to the "from address" + self.assertEqual(2, len(tx.outputs())) + self.assertTrue(tx.output_value_for_address("tb1q0fj7pxa3m2q2hlr964zn3z3wvx4t03ep5fgnhy") > 0) + self.assertEqual(49646, tx.output_value_for_address("tb1qq4pypzwxf5uanfyckmsu3ejxxf6rrvjqchza3v")) + + self.assertEqual("70736274ff0100710200000001ce010c0cab95cde544f713771916613a1a84c8787bbc95321854410b212aed9b0100000000fdffffff02cac00000000000001600147a65e09bb1da80abfc65d545388a2e61aab7c721eec100000000000016001405424089c64d39d9a498b6e1c8e646327431b240c4951e00000100de02000000000101c6edaaf0157020a38de8b07810b22ffe331d5b79c83b680dad24da15c572ae7d0000000000fdffffff026080010000000000160014eabbd791df76eeeaa3ed273cac4e1dde3be295cca0860100000000001600147a65e09bb1da80abfc65d545388a2e61aab7c7210247304402203cb8b2f84ed4fb8de5f51a07b2159bc0d8d474e5dba0f77cc66ab641cf48621b022076fb3c6b4bc76aa06dd29ebe1dd081c063cdbd2949ffcf4ab4bd8bddae6c948b0121029f16b602a6b3c738b66a03dd5133abe810169a377bbc2fdf5c5363f59b8d9bdec3951e00000000", + tx.serialize_as_bytes().hex()) + wallet.sign_transaction(tx, password=None) + tx_copy = tx_from_any(tx.serialize()) + self.assertEqual('02000000000101ce010c0cab95cde544f713771916613a1a84c8787bbc95321854410b212aed9b0100000000fdffffff02cac00000000000001600147a65e09bb1da80abfc65d545388a2e61aab7c721eec100000000000016001405424089c64d39d9a498b6e1c8e646327431b240024730440220526eac6c56cba19842b67f6c9e45af113b1a2d44fb229335bdeaf08cb2cc164e0220087fba65619016fd3f62f6c8717070e48f94b45743b86d8e0517698d2b9c3afc012102d67eaa10463f5c786271feb9ae3456c27d35c3cf6c7d881617e915d1f32cb875c4951e00', + str(tx_copy)) + + class TestWalletOfflineSigning(TestCaseForTestnet): diff --git a/electrum/wallet.py b/electrum/wallet.py index 3683eae8f..60fe5b144 100644 --- a/electrum/wallet.py +++ b/electrum/wallet.py @@ -2640,6 +2640,7 @@ class Imported_Wallet(Simple_Wallet): def __init__(self, db, storage, *, config): Abstract_Wallet.__init__(self, db, storage, config=config) + self.use_change = db.get('use_change', False) def is_watching_only(self): return self.keystore is None @@ -2682,7 +2683,7 @@ class Imported_Wallet(Simple_Wallet): return self.get_addresses() def get_change_addresses(self, **kwargs): - return [] + return self.get_addresses() def import_addresses(self, addresses: List[str], *, write_to_disk=True) -> Tuple[List[str], List[Tuple[str, str]]]: @@ -2749,6 +2750,22 @@ class Imported_Wallet(Simple_Wallet): self.save_keystore() self.save_db() + def get_change_addresses_for_new_transaction(self, *args, **kwargs) -> List[str]: + # for an imported wallet, if all "change addresses" are already used, + # it is probably better to send change back to the "from address", than to + # send it to another random used address and link them together, hence + # we force "allow_reusing_used_change_addrs=False" + return super().get_change_addresses_for_new_transaction( + *args, + **{**kwargs, "allow_reusing_used_change_addrs": False}, + ) + + def calc_unused_change_addresses(self) -> Sequence[str]: + with self.lock: + unused_addrs = [addr for addr in self.get_change_addresses() + if not self.is_used(addr) and not self.is_address_reserved(addr)] + return unused_addrs + def is_mine(self, address) -> bool: if not address: return False return self.db.has_imported_address(address)