Skip to content

Commit 8688093

Browse files
DUQUEredesclaude
andcommitted
tests: add key image spent status lifecycle test
Test is_key_image_spent RPC across all three status codes (0=UNSPENT, 1=SPENT_IN_BLOCKCHAIN, 2=SPENT_IN_POOL). The existing test in transfer.py only covers status 0 and 1. This adds coverage for the mempool-specific status 2 and verifies lifecycle transitions: pool->blockchain, pool->flush->unspent, and mixed-state batch queries. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6f284e8 commit 8688093

File tree

2 files changed

+239
-1
lines changed

2 files changed

+239
-1
lines changed

tests/functional_tests/functional_tests_rpc.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
USAGE = 'usage: functional_tests_rpc.py <python> <srcdir> <builddir> [<tests-to-run> | all]'
1313
DEFAULT_TESTS = [
1414
'address_book', 'bans', 'blockchain', 'cold_signing', 'daemon_info', 'get_output_distribution',
15-
'http_digest_auth', 'integrated_address', 'k_anonymity', 'mining', 'multisig', 'p2p', 'proofs',
15+
'http_digest_auth', 'integrated_address', 'is_key_image_spent', 'k_anonymity', 'mining', 'multisig', 'p2p', 'proofs',
1616
'rpc_payment', 'sign_message', 'transfer', 'txpool', 'uri', 'validate_address', 'wallet'
1717
]
1818
try:
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
#!/usr/bin/env python3
2+
3+
# Copyright (c) 2018-2024, The Monero Project
4+
5+
#
6+
# All rights reserved.
7+
#
8+
# Redistribution and use in source and binary forms, with or without modification, are
9+
# permitted provided that the following conditions are met:
10+
#
11+
# 1. Redistributions of source code must retain the above copyright notice, this list of
12+
# conditions and the following disclaimer.
13+
#
14+
# 2. Redistributions in binary form must reproduce the above copyright notice, this list
15+
# of conditions and the following disclaimer in the documentation and/or other
16+
# materials provided with the distribution.
17+
#
18+
# 3. Neither the name of the copyright holder nor the names of its contributors may be
19+
# used to endorse or promote products derived from this software without specific
20+
# prior written permission.
21+
#
22+
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
23+
# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
24+
# MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
25+
# THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
26+
# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
27+
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
28+
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
29+
# STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
30+
# THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
31+
32+
"""Test is_key_image_spent RPC across all three status codes
33+
34+
Tests the full lifecycle of key image spent status:
35+
- 0 = UNSPENT (key image not seen anywhere)
36+
- 1 = SPENT_IN_BLOCKCHAIN (confirmed in a block)
37+
- 2 = SPENT_IN_POOL (pending in mempool)
38+
39+
The existing test in transfer.py only covers status 0 and 1.
40+
This test adds coverage for status 2 (SPENT_IN_POOL) and
41+
lifecycle transitions between all three states.
42+
"""
43+
44+
45+
import json
46+
47+
from framework.daemon import Daemon
48+
from framework.wallet import Wallet
49+
50+
SEED = 'velvet lymph giddy number token physics poetry unquoted nibs useful sabotage limits benches lifestyle eden nitrogen anvil fewest avoid batch vials washing fences goat unquoted'
51+
MINER_ADDR = '42ey1afDFnn4886T7196doS9GPMzexD9gXpsZJDwVjeRVdFCSoHnv7KPbBeGpzJBzHRCAs9UxqeoyFQMYbqSWYTfJJQAWDm'
52+
DEST_ADDR = '888tNkZrPN6JsEgekjMnABU4TBzc2Dt29EPAvkRxbANsAnjyPbb3iQ1YBRk1UXcdRsiKc9dhwMVgN5S9cQUiyoogDavup3H'
53+
54+
55+
class KeyImageSpentTest():
56+
def run_test(self):
57+
self.reset()
58+
self.create_wallet()
59+
self.mine_blocks()
60+
self.check_unspent()
61+
self.check_spent_in_pool()
62+
self.check_spent_in_blockchain()
63+
self.check_flush_returns_to_unspent()
64+
self.check_mixed_states()
65+
self.check_edge_cases()
66+
67+
def reset(self):
68+
print('Resetting blockchain')
69+
daemon = Daemon()
70+
res = daemon.get_height()
71+
daemon.pop_blocks(res.height - 1)
72+
daemon.flush_txpool()
73+
74+
def create_wallet(self):
75+
print('Creating wallet')
76+
self.wallet = Wallet()
77+
try: self.wallet.close_wallet()
78+
except: pass
79+
self.wallet.restore_deterministic_wallet(seed = SEED)
80+
81+
def mine_blocks(self):
82+
print('Mining blocks')
83+
daemon = Daemon()
84+
daemon.generateblocks(MINER_ADDR, 130)
85+
self.wallet.refresh()
86+
res = self.wallet.get_balance()
87+
assert res.unlocked_balance > 0, 'No unlocked balance after mining'
88+
89+
def _get_input_key_images(self, tx_hash):
90+
"""Extract input key images from a transaction via decode_as_json"""
91+
daemon = Daemon()
92+
res = daemon.get_transactions([tx_hash], decode_as_json = True)
93+
tx_json = json.loads(res.txs[0].as_json)
94+
return [inp['key']['k_image'] for inp in tx_json['vin']]
95+
96+
def check_unspent(self):
97+
print('Testing UNSPENT status (0)')
98+
daemon = Daemon()
99+
100+
# All available (unspent) wallet key images should return status 0
101+
res = self.wallet.incoming_transfers(transfer_type = 'available')
102+
ki = [x.key_image for x in res.transfers]
103+
assert len(ki) > 0, 'No available transfers found'
104+
105+
res = daemon.is_key_image_spent(ki)
106+
assert res.spent_status == [0] * len(ki), \
107+
'Expected all UNSPENT (0), got: %s' % str(res.spent_status)
108+
109+
print(' All key images correctly report UNSPENT (0)')
110+
111+
def check_spent_in_pool(self):
112+
print('Testing SPENT_IN_POOL status (2)')
113+
daemon = Daemon()
114+
115+
# Send a transaction (relayed to mempool)
116+
dst = [{'address': DEST_ADDR, 'amount': 1000000000000}]
117+
res = self.wallet.transfer(dst)
118+
tx_hash = res.tx_hash
119+
120+
# Extract key images from the pending transaction
121+
ki = self._get_input_key_images(tx_hash)
122+
assert len(ki) > 0, 'No input key images found in tx'
123+
self.pool_key_images = ki
124+
125+
# Key images should now be SPENT_IN_POOL (2)
126+
res = daemon.is_key_image_spent(ki)
127+
assert res.spent_status == [2] * len(ki), \
128+
'Expected all SPENT_IN_POOL (2), got: %s' % str(res.spent_status)
129+
130+
print(' %d key image(s) correctly report SPENT_IN_POOL (2)' % len(ki))
131+
132+
def check_spent_in_blockchain(self):
133+
print('Testing SPENT_IN_POOL -> SPENT_IN_BLOCKCHAIN transition (2 -> 1)')
134+
daemon = Daemon()
135+
136+
# Mine a block to confirm the pool transaction
137+
daemon.generateblocks(MINER_ADDR, 1)
138+
self.wallet.refresh()
139+
140+
# Key images should now be SPENT_IN_BLOCKCHAIN (1)
141+
ki = self.pool_key_images
142+
res = daemon.is_key_image_spent(ki)
143+
assert res.spent_status == [1] * len(ki), \
144+
'Expected all SPENT_IN_BLOCKCHAIN (1) after mining, got: %s' % str(res.spent_status)
145+
146+
# Save one confirmed key image for mixed-state test
147+
self.confirmed_key_images = ki
148+
149+
print(' %d key image(s) correctly transitioned to SPENT_IN_BLOCKCHAIN (1)' % len(ki))
150+
151+
def check_flush_returns_to_unspent(self):
152+
print('Testing pool flush returns key images to UNSPENT (2 -> 0)')
153+
daemon = Daemon()
154+
155+
# Create a new transaction (need to refresh wallet first)
156+
self.wallet.refresh()
157+
dst = [{'address': DEST_ADDR, 'amount': 1000000000000}]
158+
res = self.wallet.transfer(dst)
159+
tx_hash = res.tx_hash
160+
161+
# Verify it's in pool
162+
ki = self._get_input_key_images(tx_hash)
163+
res = daemon.is_key_image_spent(ki)
164+
assert res.spent_status == [2] * len(ki), \
165+
'Expected SPENT_IN_POOL (2) before flush, got: %s' % str(res.spent_status)
166+
167+
# Flush the mempool
168+
daemon.flush_txpool()
169+
170+
# Key images should revert to UNSPENT (0)
171+
res = daemon.is_key_image_spent(ki)
172+
assert res.spent_status == [0] * len(ki), \
173+
'Expected UNSPENT (0) after flush, got: %s' % str(res.spent_status)
174+
175+
print(' %d key image(s) correctly reverted to UNSPENT (0) after flush' % len(ki))
176+
177+
def check_mixed_states(self):
178+
print('Testing mixed states in a single query')
179+
daemon = Daemon()
180+
181+
# Refresh wallet to pick up the flushed outputs
182+
self.wallet.refresh()
183+
184+
# Create a new pool transaction for a fresh SPENT_IN_POOL key image
185+
dst = [{'address': DEST_ADDR, 'amount': 1000000000000}]
186+
res = self.wallet.transfer(dst)
187+
tx_hash = res.tx_hash
188+
pool_ki = self._get_input_key_images(tx_hash)
189+
190+
# We now have three types of key images:
191+
# - confirmed_key_images: SPENT_IN_BLOCKCHAIN (1)
192+
# - unknown key image: UNSPENT (0)
193+
# - pool_ki: SPENT_IN_POOL (2)
194+
unknown_ki = ['ab' * 32] # 64 hex chars, never seen
195+
196+
query = self.confirmed_key_images[:1] + unknown_ki + pool_ki[:1]
197+
expected = [1, 0, 2]
198+
199+
res = daemon.is_key_image_spent(query)
200+
assert res.spent_status == expected, \
201+
'Mixed state query: expected %s, got %s' % (str(expected), str(res.spent_status))
202+
203+
# Clean up: flush the pool transaction
204+
daemon.flush_txpool()
205+
206+
print(' Mixed states [BLOCKCHAIN=1, UNSPENT=0, POOL=2] verified in single call')
207+
208+
def check_edge_cases(self):
209+
print('Testing edge cases')
210+
daemon = Daemon()
211+
212+
# Empty list: RPC omits spent_status field entirely
213+
res = daemon.is_key_image_spent([])
214+
assert 'spent_status' not in res or res.spent_status == [], \
215+
'Expected no spent_status or empty list for empty input'
216+
print(' Empty key_images list handled correctly')
217+
218+
# Unknown key images (valid hex, correct length)
219+
unknown = ['00' * 32, 'ff' * 32, 'aa' * 32]
220+
res = daemon.is_key_image_spent(unknown)
221+
assert res.spent_status == [0, 0, 0], \
222+
'Expected all UNSPENT for unknown key images, got: %s' % str(res.spent_status)
223+
print(' Unknown key images correctly return UNSPENT (0)')
224+
225+
# Duplicate key images in a single query
226+
res = self.wallet.incoming_transfers(transfer_type = 'unavailable')
227+
if 'transfers' in res and len(res.transfers) > 0:
228+
ki = res.transfers[0].key_image
229+
res = daemon.is_key_image_spent([ki, ki, ki])
230+
assert res.spent_status == [1, 1, 1], \
231+
'Expected all SPENT_IN_BLOCKCHAIN for duplicates, got: %s' % str(res.spent_status)
232+
print(' Duplicate key images handled correctly')
233+
234+
print(' Edge cases passed')
235+
236+
237+
if __name__ == '__main__':
238+
KeyImageSpentTest().run_test()

0 commit comments

Comments
 (0)