2023-07-16 10:56:36 -06:00
|
|
|
#!/usr/bin/env python3
|
|
|
|
|
2024-05-21 10:29:33 -06:00
|
|
|
# Copyright (c) 2023-2024, The Monero Project
|
2023-07-16 10:56:36 -06:00
|
|
|
#
|
|
|
|
# All rights reserved.
|
|
|
|
#
|
|
|
|
# Redistribution and use in source and binary forms, with or without modification, are
|
|
|
|
# permitted provided that the following conditions are met:
|
|
|
|
#
|
|
|
|
# 1. Redistributions of source code must retain the above copyright notice, this list of
|
|
|
|
# conditions and the following disclaimer.
|
|
|
|
#
|
|
|
|
# 2. Redistributions in binary form must reproduce the above copyright notice, this list
|
|
|
|
# of conditions and the following disclaimer in the documentation and/or other
|
|
|
|
# materials provided with the distribution.
|
|
|
|
#
|
|
|
|
# 3. Neither the name of the copyright holder nor the names of its contributors may be
|
|
|
|
# used to endorse or promote products derived from this software without specific
|
|
|
|
# prior written permission.
|
|
|
|
#
|
|
|
|
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY
|
|
|
|
# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
|
|
|
|
# MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL
|
|
|
|
# THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
|
|
|
# SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
|
|
|
|
# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
|
|
|
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
|
|
|
|
# STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF
|
|
|
|
# THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
|
|
|
|
|
|
from __future__ import print_function
|
|
|
|
import math
|
|
|
|
import random
|
|
|
|
|
|
|
|
"""
|
|
|
|
Test the k-anonymity daemon RPC features:
|
|
|
|
* txid fetching by prefix
|
|
|
|
"""
|
|
|
|
|
|
|
|
from framework.daemon import Daemon
|
|
|
|
from framework.wallet import Wallet
|
|
|
|
|
|
|
|
seeds = [
|
|
|
|
'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',
|
|
|
|
'peeled mixture ionic radar utopia puddle buying illness nuns gadget river spout cavernous bounced paradise drunk looking cottage jump tequila melting went winter adjust spout',
|
|
|
|
'tadpoles shrugged ritual exquisite deepest rest people musical farming otherwise shelter fabrics altitude seventh request tidy ivory diet vapidly syllabus logic espionage oozed opened people',
|
|
|
|
'ocio charla pomelo humilde maduro geranio bruto moño admitir mil difícil diva lucir cuatro odisea riego bebida mueble cáncer puchero carbón poeta flor fruta fruta'
|
|
|
|
]
|
|
|
|
|
|
|
|
pub_addrs = [
|
|
|
|
'42ey1afDFnn4886T7196doS9GPMzexD9gXpsZJDwVjeRVdFCSoHnv7KPbBeGpzJBzHRCAs9UxqeoyFQMYbqSWYTfJJQAWDm',
|
|
|
|
'44Kbx4sJ7JDRDV5aAhLJzQCjDz2ViLRduE3ijDZu3osWKBjMGkV1XPk4pfDUMqt1Aiezvephdqm6YD19GKFD9ZcXVUTp6BW',
|
|
|
|
'45uQD4jzWwPazqr9QJx8CmFPN7a9RaEE8T4kULg6r8GzfcrcgKXshfYf8cezLWwmENHC9pDN2fGAUFmmdFxjeZSs3n671rz',
|
|
|
|
'48hKTTTMfuiW2gDkmsibERHCjTCpqyCCh57WcU4KBeqDSAw7dG7Ad1h7v8iJF4q59aDqBATg315MuZqVmkF89E3cLPrBWsi'
|
|
|
|
]
|
|
|
|
|
|
|
|
CRYPTONOTE_MINED_MONEY_UNLOCK_WINDOW = 60
|
|
|
|
CRYPTONOTE_DEFAULT_TX_SPENDABLE_AGE = 10
|
|
|
|
RESTRICTED_SPENT_KEY_IMAGES_COUNT = 5000
|
|
|
|
|
|
|
|
def make_hash32_loose_template(txid, nbits):
|
|
|
|
txid_bytes = list(bytes.fromhex(txid))
|
|
|
|
for i in reversed(range(32)):
|
|
|
|
mask_nbits = min(8, nbits)
|
|
|
|
mask = 256 - (1 << (8 - mask_nbits))
|
|
|
|
nbits -= mask_nbits
|
|
|
|
txid_bytes[i] &= mask
|
|
|
|
return bytes(txid_bytes).hex()
|
|
|
|
|
|
|
|
def txid_list_is_sorted_in_template_order(txids):
|
|
|
|
reversed_txid_bytes = [bytes(reversed(bytes.fromhex(txid))) for txid in txids]
|
|
|
|
return sorted(reversed_txid_bytes) == reversed_txid_bytes
|
|
|
|
|
|
|
|
def txid_matches_template(txid, template, nbits):
|
|
|
|
txid_bytes = bytes.fromhex(txid)
|
|
|
|
template_bytes = bytes.fromhex(template)
|
|
|
|
for i in reversed(range(32)):
|
|
|
|
mask_nbits = min(8, nbits)
|
|
|
|
mask = 256 - (1 << (8 - mask_nbits))
|
|
|
|
nbits -= mask_nbits
|
|
|
|
if 0 != ((txid_bytes[i] ^ template_bytes[i]) & mask):
|
|
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
|
|
class KAnonymityTest:
|
|
|
|
def run_test(self):
|
|
|
|
self.reset()
|
|
|
|
self.create_wallets()
|
|
|
|
|
|
|
|
# If each of the N wallets is making N-1 transfers the first round, each N wallets needs
|
|
|
|
# N-1 unlocked coinbase outputs
|
|
|
|
N = len(seeds)
|
|
|
|
self.mine_and_refresh(2 * N * (N - 1))
|
|
|
|
self.mine_and_refresh(CRYPTONOTE_MINED_MONEY_UNLOCK_WINDOW)
|
|
|
|
|
|
|
|
# Generate a bunch of transactions
|
|
|
|
NUM_ROUNDS = 10
|
|
|
|
intermediate_mining_period = int(math.ceil(CRYPTONOTE_DEFAULT_TX_SPENDABLE_AGE / N)) * N
|
|
|
|
for i in range(NUM_ROUNDS):
|
|
|
|
self.transfer_around()
|
|
|
|
self.mine_and_refresh(intermediate_mining_period)
|
|
|
|
print("Wallets created {} transactions in {} rounds".format(len(self.wallet_txids), NUM_ROUNDS))
|
|
|
|
|
|
|
|
self.test_all_chain_txids() # Also gathers miner_txids
|
|
|
|
|
|
|
|
self.test_get_txids_loose_chain_suite()
|
|
|
|
|
|
|
|
self.test_get_txids_loose_pool_suite()
|
|
|
|
|
|
|
|
self.test_bad_txid_templates()
|
|
|
|
|
|
|
|
def reset(self):
|
|
|
|
print('Resetting blockchain')
|
|
|
|
daemon = Daemon()
|
|
|
|
res = daemon.get_height()
|
|
|
|
daemon.pop_blocks(res.height - 1)
|
|
|
|
daemon.flush_txpool()
|
|
|
|
self.wallet_txids = set()
|
|
|
|
self.total_blocks_mined = 0
|
|
|
|
self.miner_txids = set()
|
|
|
|
self.pool_txids = set()
|
|
|
|
|
|
|
|
def create_wallets(self):
|
|
|
|
print('Creating wallets')
|
|
|
|
assert len(seeds) == len(pub_addrs)
|
|
|
|
self.wallet = [None] * len(seeds)
|
|
|
|
for i in range(len(seeds)):
|
|
|
|
self.wallet[i] = Wallet(idx = i)
|
|
|
|
# close the wallet if any, will throw if none is loaded
|
|
|
|
try: self.wallet[i].close_wallet()
|
|
|
|
except: pass
|
|
|
|
res = self.wallet[i].restore_deterministic_wallet(seed = seeds[i])
|
|
|
|
|
|
|
|
def mine_and_refresh(self, num_blocks):
|
|
|
|
print("Mining {} blocks".format(num_blocks))
|
|
|
|
daemon = Daemon()
|
|
|
|
|
|
|
|
res = daemon.get_info()
|
|
|
|
old_height = res.height
|
|
|
|
|
|
|
|
assert num_blocks % len(self.wallet) == 0
|
|
|
|
assert len(self.wallet) == len(pub_addrs)
|
|
|
|
|
|
|
|
for i in range(len(self.wallet)):
|
|
|
|
daemon.generateblocks(pub_addrs[i], num_blocks // len(self.wallet))
|
|
|
|
|
|
|
|
res = daemon.get_info()
|
|
|
|
new_height = res.height
|
|
|
|
assert new_height == old_height + num_blocks, "height {} -> {}".format(old_height, new_height)
|
|
|
|
|
|
|
|
for i in range(len(self.wallet)):
|
|
|
|
self.wallet[i].refresh()
|
|
|
|
res = self.wallet[i].get_height()
|
|
|
|
assert res.height == new_height, "{} vs {}".format(res.height, new_height)
|
|
|
|
|
|
|
|
self.wallet_txids.update(self.pool_txids)
|
|
|
|
self.pool_txids.clear()
|
|
|
|
self.total_blocks_mined += num_blocks
|
|
|
|
|
|
|
|
def transfer_around(self):
|
|
|
|
N = len(self.wallet)
|
|
|
|
assert N == len(pub_addrs)
|
|
|
|
|
|
|
|
print("Creating transfers b/t wallets")
|
|
|
|
|
|
|
|
num_successful_transfers = 0
|
|
|
|
fee_margin = 0.05 # 5%
|
|
|
|
for sender in range(N):
|
|
|
|
receivers = list((r for r in range(N) if r != sender))
|
|
|
|
random.shuffle(receivers)
|
|
|
|
assert len(receivers) == N - 1
|
|
|
|
for j, receiver in enumerate(receivers):
|
|
|
|
unlocked_balance = self.wallet[sender].get_balance().unlocked_balance
|
|
|
|
if 0 == unlocked_balance:
|
|
|
|
assert j != 0 # we want all wallets to start out with at least some funds
|
|
|
|
break
|
|
|
|
imperfect_starting_balance = unlocked_balance * (N - 1) / (N - 1 - j) * (1 - fee_margin)
|
|
|
|
transfer_amount = int(imperfect_starting_balance / (N - 1))
|
|
|
|
assert transfer_amount < unlocked_balance
|
|
|
|
dst = {'address': pub_addrs[receiver], 'amount': transfer_amount}
|
|
|
|
res = self.wallet[sender].transfer([dst], get_tx_metadata = True)
|
|
|
|
tx_hex = res.tx_metadata
|
|
|
|
self.pool_txids.add(res.tx_hash)
|
|
|
|
res = self.wallet[sender].relay_tx(tx_hex)
|
|
|
|
self.wallet[sender].refresh()
|
|
|
|
num_successful_transfers += 1
|
|
|
|
|
|
|
|
print("Transferred {} times".format(num_successful_transfers))
|
|
|
|
|
|
|
|
def test_all_chain_txids(self):
|
|
|
|
daemon = Daemon()
|
|
|
|
|
|
|
|
print("Grabbing all txids from the daemon and testing against known txids")
|
|
|
|
|
|
|
|
# If assert stmt below fails, this test case needs to be rewritten to chunk the requests;
|
|
|
|
# there are simply too many txids on-chain to gather at once
|
|
|
|
expected_total_num_txids = len(self.wallet_txids) + self.total_blocks_mined + 1 # +1 for genesis coinbase tx
|
|
|
|
assert expected_total_num_txids <= RESTRICTED_SPENT_KEY_IMAGES_COUNT
|
|
|
|
|
|
|
|
res = daemon.get_txids_loose('0' * 64, 0)
|
|
|
|
all_txids = res.txids
|
|
|
|
assert 'c88ce9783b4f11190d7b9c17a69c1c52200f9faaee8e98dd07e6811175177139' in all_txids # genesis coinbase tx
|
|
|
|
assert len(all_txids) == expected_total_num_txids, "{} {}".format(len(all_txids), expected_total_num_txids)
|
|
|
|
|
|
|
|
assert txid_list_is_sorted_in_template_order(all_txids)
|
|
|
|
|
|
|
|
for txid in self.wallet_txids:
|
|
|
|
assert txid in all_txids
|
|
|
|
|
|
|
|
self.miner_txids = set(all_txids) - self.wallet_txids
|
|
|
|
|
|
|
|
def test_get_txids_loose_success(self, txid, num_matching_bits):
|
|
|
|
daemon = Daemon()
|
|
|
|
|
|
|
|
txid_template = make_hash32_loose_template(txid, num_matching_bits)
|
|
|
|
|
|
|
|
res = daemon.get_txids_loose(txid_template, num_matching_bits)
|
|
|
|
assert 'txids' in res
|
|
|
|
txids = res.txids
|
|
|
|
|
|
|
|
first_pool_index = 0
|
|
|
|
while first_pool_index < len(txids):
|
|
|
|
if txids[first_pool_index] in self.pool_txids:
|
|
|
|
break
|
|
|
|
else:
|
|
|
|
first_pool_index += 1
|
|
|
|
|
|
|
|
chain_txids = txids[:first_pool_index]
|
|
|
|
pool_txids = txids[first_pool_index:]
|
|
|
|
|
|
|
|
assert txid_list_is_sorted_in_template_order(chain_txids)
|
|
|
|
assert txid_list_is_sorted_in_template_order(pool_txids)
|
|
|
|
|
|
|
|
# Assert we know where txids came from
|
|
|
|
for txid in chain_txids:
|
|
|
|
assert (txid in self.wallet_txids) or (txid in self.miner_txids)
|
|
|
|
for txid in pool_txids:
|
|
|
|
assert txid in self.pool_txids
|
|
|
|
|
|
|
|
# Assert that all known txids were matched as they should've been
|
|
|
|
for txid in self.wallet_txids:
|
|
|
|
assert txid_matches_template(txid, txid_template, num_matching_bits) == (txid in chain_txids)
|
|
|
|
for txid in self.miner_txids:
|
|
|
|
assert txid_matches_template(txid, txid_template, num_matching_bits) == (txid in chain_txids)
|
|
|
|
for txid in self.pool_txids:
|
|
|
|
assert txid_matches_template(txid, txid_template, num_matching_bits) == (txid in pool_txids)
|
|
|
|
|
|
|
|
def test_get_txids_loose_chain_suite(self):
|
|
|
|
daemon = Daemon()
|
|
|
|
|
|
|
|
print("Testing grabbing on-chain txids loosely with all different bit sizes")
|
|
|
|
|
|
|
|
# Assert pool empty
|
|
|
|
assert len(self.pool_txids) == 0
|
|
|
|
res = daemon.get_transaction_pool_hashes()
|
|
|
|
assert not 'tx_hashes' in res or len(res.tx_hashes) == 0
|
|
|
|
|
|
|
|
assert len(self.wallet_txids)
|
|
|
|
|
|
|
|
current_chain_txids = list(self.wallet_txids.union(self.miner_txids))
|
|
|
|
for nbits in range(0, 256):
|
|
|
|
random_txid = random.choice(current_chain_txids)
|
|
|
|
self.test_get_txids_loose_success(random_txid, nbits)
|
|
|
|
|
|
|
|
def test_get_txids_loose_pool_suite(self):
|
|
|
|
daemon = Daemon()
|
|
|
|
|
|
|
|
print("Testing grabbing pool txids loosely with all different bit sizes")
|
|
|
|
|
|
|
|
# Create transactions to pool
|
|
|
|
self.transfer_around()
|
|
|
|
|
|
|
|
# Assert pool not empty
|
|
|
|
assert len(self.pool_txids) != 0
|
|
|
|
res = daemon.get_transaction_pool_hashes()
|
|
|
|
assert 'tx_hashes' in res and set(res.tx_hashes) == self.pool_txids
|
|
|
|
|
|
|
|
current_pool_txids = list(self.pool_txids)
|
|
|
|
for nbits in range(0, 256):
|
|
|
|
random_txid = random.choice(current_pool_txids)
|
|
|
|
self.test_get_txids_loose_success(random_txid, nbits)
|
|
|
|
|
|
|
|
def test_bad_txid_templates(self):
|
|
|
|
daemon = Daemon()
|
|
|
|
|
|
|
|
print("Making sure the daemon catches bad txid templates")
|
|
|
|
|
|
|
|
test_cases = [
|
|
|
|
['q', 256],
|
|
|
|
['a', 128],
|
|
|
|
['69' * 32, 257],
|
|
|
|
['0abcdef1234567890abcdef1234567890abcdef1234567890abcdef123456789', 0],
|
|
|
|
['0abcdef1234567890abcdef1234567890abcdef1234567890abcdef123456789', 1],
|
|
|
|
['0abcdef1234567890abcdef1234567890abcdef1234567890abcdef123456789', 2],
|
|
|
|
['0abcdef1234567890abcdef1234567890abcdef1234567890abcdef123456789', 4],
|
|
|
|
['0abcdef1234567890abcdef1234567890abcdef1234567890abcdef123456789', 8],
|
|
|
|
['0abcdef1234567890abcdef1234567890abcdef1234567890abcdef123456789', 16],
|
|
|
|
['0abcdef1234567890abcdef1234567890abcdef1234567890abcdef123456789', 32],
|
|
|
|
['0abcdef1234567890abcdef1234567890abcdef1234567890abcdef123456789', 64],
|
|
|
|
['0abcdef1234567890abcdef1234567890abcdef1234567890abcdef123456789', 128],
|
|
|
|
['0abcdef1234567890abcdef1234567890abcdef1234567890abcdef123456789', 193],
|
|
|
|
['0000000000000000000000000000000000000000000000000000000000000080', 0],
|
|
|
|
['0000000000000000000000000000000000000000000000000000000000000007', 5],
|
|
|
|
['00000000000000000000000000000000000000000000000000000000000000f7', 5],
|
|
|
|
]
|
|
|
|
|
|
|
|
for txid_template, num_matching_bits in test_cases:
|
|
|
|
ok = False
|
|
|
|
try: res = daemon.get_txids_loose(txid_template, num_matching_bits)
|
|
|
|
except: ok = True
|
|
|
|
assert ok, 'bad template didnt error: {} {}'.format(txid_template, num_matching_bits)
|
|
|
|
|
|
|
|
if __name__ == '__main__':
|
|
|
|
KAnonymityTest().run_test()
|