Skip to content

Commit 59efdf8

Browse files
Wallet update locked transaction tests
* Add common wallet tests `test_update_locked_same_account`, `test_update_locked_same_account_split`, `test_update_locked_different_accounts`, `test_update_locked_different_accounts_split` * Implement `SendUpdateTxsTester` * Remove `TxUtils.log_txs`
1 parent 4438fd3 commit 59efdf8

6 files changed

Lines changed: 313 additions & 20 deletions

File tree

tests/test_monero_wallet_common.py

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
WalletType, IntegrationTestUtils,
2828
ViewOnlyAndOfflineWalletTester,
2929
WalletNotificationCollector,
30-
MiningUtils
30+
MiningUtils, SendAndUpdateTxsTester
3131
)
3232

3333
logger: logging.Logger = logging.getLogger("TestMoneroWalletCommon")
@@ -559,6 +559,58 @@ def test_sweep_dust(self, wallet: MoneroWallet) -> None:
559559
for tx in txs:
560560
TxUtils.test_tx_wallet(tx, ctx)
561561

562+
# TODO: test sending to multiple accounts
563+
# Can update a locked tx sent from/to the same account as blocks are added to the chain
564+
@pytest.mark.skipif(TestUtils.TEST_RELAYS is False, reason="TEST_RELAYS disabled")
565+
@pytest.mark.skipif(TestUtils.TEST_NOTIFICATIONS is False, reason="TEST_NOTIFICATIONS disabled")
566+
def test_update_locked_same_account(self, daemon: MoneroDaemonRpc, wallet: MoneroWallet) -> None:
567+
config: MoneroTxConfig = MoneroTxConfig()
568+
config.address = wallet.get_primary_address()
569+
config.amount = TxUtils.MAX_FEE
570+
config.account_index = 0
571+
config.can_split = False
572+
config.relay = True
573+
self._test_send_and_update_txs(daemon, wallet, config)
574+
575+
# Can update split locked txs sent from/to the same account as blocks are added to the chain
576+
@pytest.mark.skipif(TestUtils.TEST_RELAYS is False, reason="TEST_RELAYS disabled")
577+
@pytest.mark.skipif(TestUtils.TEST_NOTIFICATIONS is False, reason="TEST_NOTIFICATIONS disabled")
578+
@pytest.mark.skipif(TestUtils.LITE_MODE, reason="LITE_MODE enabled")
579+
def test_update_locked_same_account_split(self, daemon: MoneroDaemonRpc, wallet: MoneroWallet) -> None:
580+
config: MoneroTxConfig = MoneroTxConfig()
581+
config.address = wallet.get_primary_address()
582+
config.amount = TxUtils.MAX_FEE
583+
config.account_index = 0
584+
config.can_split = True
585+
config.relay = True
586+
self._test_send_and_update_txs(daemon, wallet, config)
587+
588+
# Can update a locked tx sent from/to different accounts as blocks are added to the chain
589+
@pytest.mark.skipif(TestUtils.TEST_RELAYS is False, reason="TEST_RELAYS disabled")
590+
@pytest.mark.skipif(TestUtils.TEST_NOTIFICATIONS is False, reason="TEST_NOTIFICATIONS disabled")
591+
@pytest.mark.skipif(TestUtils.LITE_MODE, reason="LITE_MODE enabled")
592+
def test_update_locked_different_accounts(self, daemon: MoneroDaemonRpc, wallet: MoneroWallet) -> None:
593+
config: MoneroTxConfig = MoneroTxConfig()
594+
config.address = wallet.get_subaddress(1, 0).address
595+
config.amount = TxUtils.MAX_FEE
596+
config.account_index = 0
597+
config.can_split = False
598+
config.relay = True
599+
self._test_send_and_update_txs(daemon, wallet, config)
600+
601+
# Can update locked, split txs sent from/to different accounts as blocks are added to the chain
602+
@pytest.mark.skipif(TestUtils.TEST_RELAYS is False, reason="TEST_RELAYS disabled")
603+
@pytest.mark.skipif(TestUtils.TEST_NOTIFICATIONS is False, reason="TEST_NOTIFICATIONS disabled")
604+
@pytest.mark.skipif(TestUtils.LITE_MODE, reason="LITE_MODE enabled")
605+
def test_update_locked_different_accounts_split(self, daemon: MoneroDaemonRpc, wallet: MoneroWallet) -> None:
606+
config: MoneroTxConfig = MoneroTxConfig()
607+
config.address = wallet.get_subaddress(1, 0).address
608+
config.amount = TxUtils.MAX_FEE
609+
config.account_index = 0
610+
config.can_split = True
611+
config.relay = True
612+
self._test_send_and_update_txs(daemon, wallet, config)
613+
562614
#endregion
563615

564616
#region Non Relays Tests
@@ -3755,4 +3807,13 @@ def test_rescan_blockchain(self, wallet: MoneroWallet) -> None:
37553807

37563808
#endregion
37573809

3810+
#region Utils
3811+
3812+
@classmethod
3813+
def _test_send_and_update_txs(cls, daemon: MoneroDaemonRpc, wallet: MoneroWallet, config: MoneroTxConfig) -> None:
3814+
tester: SendAndUpdateTxsTester = SendAndUpdateTxsTester(daemon, wallet, config)
3815+
tester.test()
3816+
3817+
#endregion
3818+
37583819
#endregion

tests/test_monero_wallet_full.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,18 @@ def get_test_wallet(cls) -> MoneroWalletFull:
108108

109109
#endregion
110110

111+
#region Test Relays
112+
113+
@pytest.mark.skipif(Utils.TEST_RELAYS is False, reason="TEST_RELAYS disabled")
114+
@pytest.mark.skipif(Utils.TEST_NOTIFICATIONS is False, reason="TEST_NOTIFICATIONS disabled")
115+
@pytest.mark.skipif(Utils.LITE_MODE, reason="LITE_MODE enabled")
116+
@pytest.mark.xfail(raises=RuntimeError, reason="TODO Cannot reconcile integrals: 0 vs 1. tx wallet m_is_incoming")
117+
@override
118+
def test_update_locked_different_accounts_split(self, daemon: MoneroDaemonRpc, wallet: MoneroWallet) -> None:
119+
return super().test_update_locked_different_accounts_split(daemon, wallet)
120+
121+
#endregion
122+
111123
#region Test Non Relays
112124

113125
# Can create a random full wallet
@@ -660,10 +672,6 @@ def test_multisig_sample(self) -> None:
660672
self._test_multisig_sample(2, 3)
661673
self._test_multisig_sample(2, 4)
662674

663-
#endregion
664-
665-
#region Disabled Tests
666-
667675
@pytest.mark.skipif(Utils.REGTEST, reason="Cannot retrieve accurate height by date from regtest fakechain")
668676
@pytest.mark.skipif(Utils.TEST_NON_RELAYS is False, reason="TEST_NON_RELAYS disabled")
669677
@override
@@ -676,6 +684,10 @@ def test_get_height_by_date(self, wallet: MoneroWallet):
676684
def test_get_height_by_date_regtest(self, wallet: MoneroWallet):
677685
return super().test_get_height_by_date(wallet)
678686

687+
#endregion
688+
689+
#region Disabled Tests
690+
679691
@pytest.mark.skip(reason="TODO disabled because importing key images deletes corresponding incoming transfers: https://github.com/monero-project/monero/issues/5812")
680692
@override
681693
def test_import_key_images(self, wallet: MoneroWallet):

tests/test_monero_wallet_keys.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -528,6 +528,26 @@ def test_is_multisig_needed(self, wallet: MoneroWallet) -> None:
528528
def test_rescan_spent(self, wallet: MoneroWallet) -> None:
529529
return super().test_rescan_spent(wallet)
530530

531+
@pytest.mark.not_supported
532+
@override
533+
def test_update_locked_same_account(self, daemon: MoneroDaemonRpc, wallet: MoneroWallet) -> None:
534+
return super().test_update_locked_same_account(daemon, wallet)
535+
536+
@pytest.mark.not_supported
537+
@override
538+
def test_update_locked_same_account_split(self, daemon: MoneroDaemonRpc, wallet: MoneroWallet) -> None:
539+
return super().test_update_locked_same_account_split(daemon, wallet)
540+
541+
@pytest.mark.not_supported
542+
@override
543+
def test_update_locked_different_accounts(self, daemon: MoneroDaemonRpc, wallet: MoneroWallet) -> None:
544+
return super().test_update_locked_different_accounts(daemon, wallet)
545+
546+
@pytest.mark.not_supported
547+
@override
548+
def test_update_locked_different_accounts_split(self, daemon: MoneroDaemonRpc, wallet: MoneroWallet) -> None:
549+
return super().test_update_locked_different_accounts_split(daemon, wallet)
550+
531551
#endregion
532552

533553
#region Tests

tests/utils/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
from .wallet_sync_tester import WalletSyncTester
3232
from .sync_progress_tester import SyncProgressTester
3333
from .sync_seed_tester import SyncSeedTester
34+
from .send_and_update_txs_tester import SendAndUpdateTxsTester
3435

3536
__all__ = [
3637
'WalletUtils',
@@ -65,5 +66,6 @@
6566
'MultisigSampleCodeTester',
6667
'WalletSyncTester',
6768
'SyncProgressTester',
68-
'SyncSeedTester'
69+
'SyncSeedTester',
70+
'SendAndUpdateTxsTester'
6971
]
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
import logging
2+
3+
from time import sleep
4+
from monero import (
5+
MoneroWallet, MoneroTxConfig, MoneroTxWallet,
6+
MoneroDaemon, MoneroBlockHeader, MoneroTxQuery
7+
)
8+
9+
from .test_utils import TestUtils
10+
from .tx_utils import TxUtils
11+
from .mining_utils import MiningUtils
12+
from .tx_context import TxContext
13+
14+
logger: logging.Logger = logging.getLogger("SendAndUpdateTxsTester")
15+
16+
17+
class SendAndUpdateTxsTester:
18+
"""
19+
Tests sending a tx with an unlock time then tracking and updating it as
20+
blocks are added to the chain.
21+
22+
TODO: test wallet accounting throughout this; dedicated method? probably.
23+
24+
Allows sending to and from the same account which is an edge case where
25+
incoming txs are occluded by their outgoing counterpart (issue #4500)
26+
and also where it is impossible to discern which incoming output is
27+
the tx amount and which is the change amount without wallet metadata.
28+
"""
29+
30+
daemon: MoneroDaemon
31+
"""Daemon test instance."""
32+
wallet: MoneroWallet
33+
"""The test wallet to send from."""
34+
config: MoneroTxConfig
35+
"""The send configuration to send and test."""
36+
num_confirmations: int
37+
"""Number of blockchain confirmations."""
38+
39+
def __init__(self, daemon: MoneroDaemon, wallet: MoneroWallet, config: MoneroTxConfig) -> None:
40+
"""
41+
Initialize a new send and update txs tester.
42+
43+
:param MoneroDaemon daemon: daemon test instance.
44+
:param MoneroWallet wallet: wallet test instance.
45+
:param MoneroWalletConfig config: txs send configuration to test.
46+
"""
47+
self.daemon = daemon
48+
self.wallet = wallet
49+
self.config = config
50+
self.num_confirmations = 0
51+
52+
def setup(self) -> None:
53+
"""Setup test wallet."""
54+
# wait for txs to confirm and for sufficient unlocked balance
55+
TestUtils.WALLET_TX_TRACKER.wait_for_txs_to_clear_pool(self.wallet)
56+
assert len(self.config.subaddress_indices) == 0
57+
assert self.config.account_index is not None
58+
fee: int = TxUtils.MAX_FEE * 2
59+
TestUtils.WALLET_TX_TRACKER.wait_for_unlocked_balance(self.wallet, self.config.account_index, None, fee)
60+
61+
def test_unlock_tx(self, tx: MoneroTxWallet, is_send_response: bool) -> None:
62+
"""
63+
Test tx unlock.
64+
65+
:param MoneroTxWallet tx: transaction to test.
66+
:param bool is_send_response: indicates if tx originated from send response.
67+
"""
68+
ctx: TxContext = TxContext()
69+
ctx.wallet = self.wallet
70+
ctx.config = self.config
71+
ctx.is_send_response = is_send_response
72+
try:
73+
TxUtils.test_tx_wallet(tx, ctx)
74+
except Exception as e:
75+
logger.warning(e)
76+
raise
77+
78+
def test_out_in_pair(self, tx_out: MoneroTxWallet, tx_in: MoneroTxWallet) -> None:
79+
"""
80+
Test transaction out / in pair.
81+
82+
:param MoneroTxWallet tx_out: outgoing transaction.
83+
:param MoneroTxWallet tx_in: incoming transaction.
84+
"""
85+
assert tx_out.is_confirmed == tx_in.is_confirmed
86+
assert tx_in.get_incoming_amount() == tx_out.get_outgoing_amount()
87+
88+
def test_out_in_pairs(self, txs: list[MoneroTxWallet], is_send_response: bool) -> None:
89+
"""
90+
Test transactions out / in pairs.
91+
92+
:param list[MoneroTxWallet] txs: transactions to test out / in pairs.
93+
:param bool is_send_response: indicates if tx originated from send response.
94+
"""
95+
# for each out tx
96+
for tx in txs:
97+
self.test_unlock_tx(tx, is_send_response)
98+
if tx.outgoing_transfer is not None:
99+
continue
100+
101+
tx_out: MoneroTxWallet = tx
102+
103+
# find incoming counterpart
104+
tx_in: MoneroTxWallet | None = None
105+
for tx2 in txs:
106+
if tx.is_incoming and tx.hash == tx2.hash:
107+
tx_in = tx2
108+
break
109+
110+
# test out / in pair
111+
# TODO monero-wallet-rpc: incoming txs occluded by their outgoing counterpart #4500
112+
if tx_in is None:
113+
logger.warning(f"outgoing tx {tx.hash} missing incoming counterpart (issue #4500)")
114+
else:
115+
self.test_out_in_pair(tx_out, tx_in)
116+
117+
def wait_for_confirmations(self, sent_txs: list[MoneroTxWallet], num_confirmations_total: int) -> None:
118+
"""
119+
Wait for txs to confirm.
120+
121+
:param list[MoneroTxWallet] sent_txs: list of sent transactions.
122+
:param int num_confirmations_total: number of confirmed txs required.
123+
"""
124+
# track resulting outgoing and incoming txs as blocks are added to the chain
125+
updated_txs: list[MoneroTxWallet] | None = None
126+
logger.info(f"{self.num_confirmations} < {num_confirmations_total} needed confirmations")
127+
header: MoneroBlockHeader = self.daemon.wait_for_next_block_header()
128+
logger.info(f"*** Block {header.height} added to chain ***")
129+
130+
# give wallet time to catch up, otherwise incoming tx may not appear
131+
# TODO: this lets new block slip, okay?
132+
sleep(TestUtils.SYNC_PERIOD_IN_MS / 1000)
133+
134+
# get incoming/outgoing txs with sent hashes
135+
tx_hashes: list[str] = []
136+
for sent_tx in sent_txs:
137+
assert sent_tx.hash is not None
138+
tx_hashes.append(sent_tx.hash)
139+
140+
query: MoneroTxQuery = MoneroTxQuery()
141+
query.hashes = tx_hashes
142+
fetched_txs: list[MoneroTxWallet] = TxUtils.get_and_test_txs(self.wallet, query, None, True, TestUtils.REGTEST)
143+
assert len(fetched_txs) > 0
144+
145+
# test fetched txs
146+
self.test_out_in_pairs(fetched_txs, False)
147+
148+
# merge fetched txs into updated txs and original sent txs
149+
for fetched_tx in fetched_txs:
150+
# merge with updated txs
151+
if updated_txs is None:
152+
updated_txs = fetched_txs
153+
else:
154+
for updated_tx in updated_txs:
155+
if fetched_tx.hash != updated_tx.hash or fetched_tx.is_outgoing != updated_tx.is_outgoing:
156+
continue
157+
updated_tx.merge(fetched_tx.copy())
158+
if updated_tx.block is None and fetched_tx.block is not None:
159+
# copy block for testing
160+
updated_tx.block = fetched_tx.block.copy()
161+
updated_tx.block.txs = [updated_tx]
162+
163+
# merge with original sent txs
164+
for sent_tx in sent_txs:
165+
if fetched_tx.hash != sent_tx.hash or fetched_tx.is_outgoing != sent_tx.is_outgoing:
166+
continue
167+
# TODO: it's mergeable but tests don't account for extra info
168+
# from send (e.g. hex) so not tested; could specify in test config
169+
sent_tx.merge(fetched_tx.copy())
170+
171+
# test updated txs
172+
assert updated_txs is not None
173+
self.test_out_in_pairs(updated_txs, False)
174+
175+
# update confirmations in order to exit loop
176+
fetched_tx = fetched_txs[0]
177+
assert fetched_tx.num_confirmations is not None
178+
self.num_confirmations = fetched_tx.num_confirmations
179+
180+
def test(self) -> None:
181+
"""Run send and update txs test."""
182+
self.setup()
183+
# this test starts and stops mining, so it's wrapped in order to stop mining if anything fails
184+
try:
185+
# send transactions
186+
sent_txs: list[MoneroTxWallet] = self.wallet.create_txs(self.config)
187+
188+
# build test context
189+
ctx: TxContext = TxContext()
190+
ctx.wallet = self.wallet
191+
ctx.config = self.config
192+
ctx.is_send_response = True
193+
194+
# test sent transactions
195+
for tx in sent_txs:
196+
TxUtils.test_tx_wallet(tx, ctx)
197+
assert tx.is_confirmed is False
198+
assert tx.in_tx_pool is True
199+
200+
# attemp to start mining to push the network along
201+
MiningUtils.try_start_mining()
202+
203+
# number of confirmations to test
204+
num_confirmations_total: int = 2
205+
# loop to update txs through confirmations
206+
207+
while self.num_confirmations < num_confirmations_total:
208+
self.wait_for_confirmations(sent_txs, num_confirmations_total)
209+
210+
finally:
211+
# stop mining at end of test
212+
MiningUtils.try_stop_mining()

0 commit comments

Comments
 (0)