mirror of
https://git.wownero.com/wownero/wownero.git
synced 2024-08-15 01:03:23 +00:00
wallet2: reuse fake outs when adjusting fee on transfer
This avoids indirectly leaking the real output to the daemon, and is faster. This will still happen for more complex cases, especially when cancelling a tx and "re-rolling" it.
This commit is contained in:
parent
64da0983d5
commit
d86ae2bec6
3 changed files with 55 additions and 23 deletions
|
@ -3400,8 +3400,7 @@ std::vector<wallet2::pending_tx> wallet2::create_transactions(std::vector<crypto
|
|||
}
|
||||
}
|
||||
|
||||
template<typename entry>
|
||||
void wallet2::get_outs(std::vector<std::vector<entry>> &outs, const std::list<size_t> &selected_transfers, size_t fake_outputs_count)
|
||||
void wallet2::get_outs(std::vector<std::vector<tools::wallet2::get_outs_entry>> &outs, const std::list<size_t> &selected_transfers, size_t fake_outputs_count)
|
||||
{
|
||||
LOG_PRINT_L2("fake_outputs_count: " << fake_outputs_count);
|
||||
outs.clear();
|
||||
|
@ -3492,6 +3491,7 @@ void wallet2::get_outs(std::vector<std::vector<entry>> &outs, const std::list<si
|
|||
uint64_t num_found = 1;
|
||||
seen_indices.emplace(td.m_global_output_index);
|
||||
req.outputs.push_back({amount, td.m_global_output_index});
|
||||
LOG_PRINT_L1("Selecting real output: " << td.m_global_output_index << " for " << print_money(amount));
|
||||
|
||||
// while we still need more mixins
|
||||
while (num_found < requested_outputs_count)
|
||||
|
@ -3563,7 +3563,7 @@ void wallet2::get_outs(std::vector<std::vector<entry>> &outs, const std::list<si
|
|||
{
|
||||
const transfer_details &td = m_transfers[idx];
|
||||
size_t requested_outputs_count = base_requested_outputs_count + (td.is_rct() ? CRYPTONOTE_MINED_MONEY_UNLOCK_WINDOW - CRYPTONOTE_DEFAULT_TX_SPENDABLE_AGE : 0);
|
||||
outs.push_back(std::vector<entry>());
|
||||
outs.push_back(std::vector<get_outs_entry>());
|
||||
outs.back().reserve(fake_outputs_count + 1);
|
||||
const rct::key mask = td.is_rct() ? rct::commit(td.amount(), td.m_mask) : rct::zeroCommit(td.amount());
|
||||
|
||||
|
@ -3616,7 +3616,7 @@ void wallet2::get_outs(std::vector<std::vector<entry>> &outs, const std::list<si
|
|||
else
|
||||
{
|
||||
// sort the subsection, so any spares are reset in order
|
||||
std::sort(outs.back().begin(), outs.back().end(), [](const entry &a, const entry &b) { return std::get<0>(a) < std::get<0>(b); });
|
||||
std::sort(outs.back().begin(), outs.back().end(), [](const get_outs_entry &a, const get_outs_entry &b) { return std::get<0>(a) < std::get<0>(b); });
|
||||
}
|
||||
base += requested_outputs_count;
|
||||
}
|
||||
|
@ -3627,7 +3627,7 @@ void wallet2::get_outs(std::vector<std::vector<entry>> &outs, const std::list<si
|
|||
for (size_t idx: selected_transfers)
|
||||
{
|
||||
const transfer_details &td = m_transfers[idx];
|
||||
std::vector<entry> v;
|
||||
std::vector<get_outs_entry> v;
|
||||
const rct::key mask = td.is_rct() ? rct::commit(td.amount(), td.m_mask) : rct::zeroCommit(td.amount());
|
||||
v.push_back(std::make_tuple(td.m_global_output_index, boost::get<txout_to_key>(td.m_tx.vout[td.m_internal_output_index].target).key, mask));
|
||||
outs.push_back(v);
|
||||
|
@ -3637,6 +3637,7 @@ void wallet2::get_outs(std::vector<std::vector<entry>> &outs, const std::list<si
|
|||
|
||||
template<typename T>
|
||||
void wallet2::transfer_selected(const std::vector<cryptonote::tx_destination_entry>& dsts, const std::list<size_t> selected_transfers, size_t fake_outputs_count,
|
||||
std::vector<std::vector<tools::wallet2::get_outs_entry>> &outs,
|
||||
uint64_t unlock_time, uint64_t fee, const std::vector<uint8_t>& extra, T destination_split_strategy, const tx_dust_policy& dust_policy, cryptonote::transaction& tx, pending_tx &ptx)
|
||||
{
|
||||
using namespace cryptonote;
|
||||
|
@ -3666,11 +3667,11 @@ void wallet2::transfer_selected(const std::vector<cryptonote::tx_destination_ent
|
|||
LOG_PRINT_L2("wanted " << print_money(needed_money) << ", found " << print_money(found_money) << ", fee " << print_money(fee));
|
||||
THROW_WALLET_EXCEPTION_IF(found_money < needed_money, error::not_enough_money, found_money, needed_money - fee, fee);
|
||||
|
||||
typedef std::tuple<uint64_t, crypto::public_key, rct::key> entry;
|
||||
std::vector<std::vector<entry>> outs;
|
||||
if (outs.empty())
|
||||
get_outs(outs, selected_transfers, fake_outputs_count); // may throw
|
||||
|
||||
//prepare inputs
|
||||
LOG_PRINT_L2("preparing outputs");
|
||||
typedef cryptonote::tx_source_entry::output_entry tx_output_entry;
|
||||
size_t i = 0, out_index = 0;
|
||||
std::vector<cryptonote::tx_source_entry> sources;
|
||||
|
@ -3713,6 +3714,7 @@ void wallet2::transfer_selected(const std::vector<cryptonote::tx_destination_ent
|
|||
detail::print_source_entry(src);
|
||||
++out_index;
|
||||
}
|
||||
LOG_PRINT_L2("outputs prepared");
|
||||
|
||||
cryptonote::tx_destination_entry change_dts = AUTO_VAL_INIT(change_dts);
|
||||
if (needed_money < found_money)
|
||||
|
@ -3735,7 +3737,9 @@ void wallet2::transfer_selected(const std::vector<cryptonote::tx_destination_ent
|
|||
}
|
||||
|
||||
crypto::secret_key tx_key;
|
||||
LOG_PRINT_L2("constructing tx");
|
||||
bool r = cryptonote::construct_tx_and_get_tx_key(m_account.get_keys(), sources, splitted_dsts, extra, tx, unlock_time, tx_key);
|
||||
LOG_PRINT_L2("constructed tx, r="<<r);
|
||||
THROW_WALLET_EXCEPTION_IF(!r, error::tx_not_constructed, sources, splitted_dsts, unlock_time, m_testnet);
|
||||
THROW_WALLET_EXCEPTION_IF(upper_transaction_size_limit <= get_object_blobsize(tx), error::tx_too_big, tx, upper_transaction_size_limit);
|
||||
|
||||
|
@ -3771,9 +3775,11 @@ void wallet2::transfer_selected(const std::vector<cryptonote::tx_destination_ent
|
|||
ptx.construction_data.unlock_time = unlock_time;
|
||||
ptx.construction_data.use_rct = false;
|
||||
ptx.construction_data.dests = dsts;
|
||||
LOG_PRINT_L2("transfer_selected done");
|
||||
}
|
||||
|
||||
void wallet2::transfer_selected_rct(std::vector<cryptonote::tx_destination_entry> dsts, const std::list<size_t> selected_transfers, size_t fake_outputs_count,
|
||||
std::vector<std::vector<tools::wallet2::get_outs_entry>> &outs,
|
||||
uint64_t unlock_time, uint64_t fee, const std::vector<uint8_t>& extra, cryptonote::transaction& tx, pending_tx &ptx)
|
||||
{
|
||||
using namespace cryptonote;
|
||||
|
@ -3782,7 +3788,7 @@ void wallet2::transfer_selected_rct(std::vector<cryptonote::tx_destination_entry
|
|||
|
||||
uint64_t upper_transaction_size_limit = get_upper_tranaction_size_limit();
|
||||
uint64_t needed_money = fee;
|
||||
LOG_PRINT_L2("transfer: starting with fee " << print_money (needed_money));
|
||||
LOG_PRINT_L2("transfer_selected_rct: starting with fee " << print_money (needed_money));
|
||||
LOG_PRINT_L0("selected transfers: ");
|
||||
for (auto t: selected_transfers)
|
||||
LOG_PRINT_L2(" " << t);
|
||||
|
@ -3806,11 +3812,11 @@ void wallet2::transfer_selected_rct(std::vector<cryptonote::tx_destination_entry
|
|||
LOG_PRINT_L2("wanted " << print_money(needed_money) << ", found " << print_money(found_money) << ", fee " << print_money(fee));
|
||||
THROW_WALLET_EXCEPTION_IF(found_money < needed_money, error::not_enough_money, found_money, needed_money - fee, fee);
|
||||
|
||||
typedef std::tuple<uint64_t, crypto::public_key, rct::key> entry;
|
||||
std::vector<std::vector<entry>> outs;
|
||||
if (outs.empty())
|
||||
get_outs(outs, selected_transfers, fake_outputs_count); // may throw
|
||||
|
||||
//prepare inputs
|
||||
LOG_PRINT_L2("preparing outputs");
|
||||
size_t i = 0, out_index = 0;
|
||||
std::vector<cryptonote::tx_source_entry> sources;
|
||||
BOOST_FOREACH(size_t idx, selected_transfers)
|
||||
|
@ -3853,6 +3859,7 @@ void wallet2::transfer_selected_rct(std::vector<cryptonote::tx_destination_entry
|
|||
detail::print_source_entry(src);
|
||||
++out_index;
|
||||
}
|
||||
LOG_PRINT_L2("outputs prepared");
|
||||
|
||||
// we still keep a copy, since we want to keep dsts free of change for user feedback purposes
|
||||
std::vector<cryptonote::tx_destination_entry> splitted_dsts = dsts;
|
||||
|
@ -3864,9 +3871,11 @@ void wallet2::transfer_selected_rct(std::vector<cryptonote::tx_destination_entry
|
|||
// the sender with a 0 amount output. We send a 0 amount in order to avoid
|
||||
// letting the destination be able to work out which of the inputs is the
|
||||
// real one in our rings
|
||||
LOG_PRINT_L2("generating dummy address for 0 change");
|
||||
cryptonote::account_base dummy;
|
||||
dummy.generate();
|
||||
change_dts.addr = dummy.get_keys().m_account_address;
|
||||
LOG_PRINT_L2("generated dummy address for 0 change");
|
||||
}
|
||||
else
|
||||
{
|
||||
|
@ -3875,10 +3884,13 @@ void wallet2::transfer_selected_rct(std::vector<cryptonote::tx_destination_entry
|
|||
splitted_dsts.push_back(change_dts);
|
||||
|
||||
crypto::secret_key tx_key;
|
||||
LOG_PRINT_L2("constructing tx");
|
||||
bool r = cryptonote::construct_tx_and_get_tx_key(m_account.get_keys(), sources, splitted_dsts, extra, tx, unlock_time, tx_key, true);
|
||||
LOG_PRINT_L2("constructed tx, r="<<r);
|
||||
THROW_WALLET_EXCEPTION_IF(!r, error::tx_not_constructed, sources, dsts, unlock_time, m_testnet);
|
||||
THROW_WALLET_EXCEPTION_IF(upper_transaction_size_limit <= get_object_blobsize(tx), error::tx_too_big, tx, upper_transaction_size_limit);
|
||||
|
||||
LOG_PRINT_L2("gathering key images");
|
||||
std::string key_images;
|
||||
bool all_are_txin_to_key = std::all_of(tx.vin.begin(), tx.vin.end(), [&](const txin_v& s_e) -> bool
|
||||
{
|
||||
|
@ -3887,6 +3899,7 @@ void wallet2::transfer_selected_rct(std::vector<cryptonote::tx_destination_entry
|
|||
return true;
|
||||
});
|
||||
THROW_WALLET_EXCEPTION_IF(!all_are_txin_to_key, error::unexpected_txin_type, tx);
|
||||
LOG_PRINT_L2("gathered key images");
|
||||
|
||||
ptx.key_images = key_images;
|
||||
ptx.fee = fee;
|
||||
|
@ -3905,6 +3918,7 @@ void wallet2::transfer_selected_rct(std::vector<cryptonote::tx_destination_entry
|
|||
ptx.construction_data.unlock_time = unlock_time;
|
||||
ptx.construction_data.use_rct = true;
|
||||
ptx.construction_data.dests = dsts;
|
||||
LOG_PRINT_L2("transfer_selected_rct done");
|
||||
}
|
||||
|
||||
static size_t estimate_rct_tx_size(int n_inputs, int mixin, int n_outputs)
|
||||
|
@ -4106,12 +4120,14 @@ std::vector<wallet2::pending_tx> wallet2::create_transactions_2(std::vector<cryp
|
|||
accumulated_change = 0;
|
||||
adding_fee = false;
|
||||
needed_fee = 0;
|
||||
std::vector<std::vector<tools::wallet2::get_outs_entry>> outs;
|
||||
|
||||
// for rct, since we don't see the amounts, we will try to make all transactions
|
||||
// look the same, with 1 or 2 inputs, and 2 outputs. One input is preferable, as
|
||||
// this prevents linking to another by provenance analysis, but two is ok if we
|
||||
// try to pick outputs not from the same block. We will get two outputs, one for
|
||||
// the destination, and one for change.
|
||||
LOG_PRINT_L2("checking preferred");
|
||||
std::vector<size_t> prefered_inputs;
|
||||
uint64_t rct_outs_needed = 2 * (fake_outs_count + 1);
|
||||
rct_outs_needed += 100; // some fudge factor since we don't know how many are locked
|
||||
|
@ -4128,6 +4144,7 @@ std::vector<wallet2::pending_tx> wallet2::create_transactions_2(std::vector<cryp
|
|||
LOG_PRINT_L1("Found prefered rct inputs for rct tx: " << s);
|
||||
}
|
||||
}
|
||||
LOG_PRINT_L2("done checking preferred");
|
||||
|
||||
// while:
|
||||
// - we have something to send
|
||||
|
@ -4161,6 +4178,9 @@ std::vector<wallet2::pending_tx> wallet2::create_transactions_2(std::vector<cryp
|
|||
uint64_t available_amount = td.amount();
|
||||
accumulated_outputs += available_amount;
|
||||
|
||||
// clear any fake outs we'd already gathered, since we'll need a new set
|
||||
outs.clear();
|
||||
|
||||
if (adding_fee)
|
||||
{
|
||||
LOG_PRINT_L2("We need more fee, adding it to fee");
|
||||
|
@ -4217,10 +4237,10 @@ std::vector<wallet2::pending_tx> wallet2::create_transactions_2(std::vector<cryp
|
|||
LOG_PRINT_L2("Trying to create a tx now, with " << tx.dsts.size() << " destinations and " <<
|
||||
tx.selected_transfers.size() << " outputs");
|
||||
if (use_rct)
|
||||
transfer_selected_rct(tx.dsts, tx.selected_transfers, fake_outs_count, unlock_time, needed_fee, extra,
|
||||
transfer_selected_rct(tx.dsts, tx.selected_transfers, fake_outs_count, outs, unlock_time, needed_fee, extra,
|
||||
test_tx, test_ptx);
|
||||
else
|
||||
transfer_selected(tx.dsts, tx.selected_transfers, fake_outs_count, unlock_time, needed_fee, extra,
|
||||
transfer_selected(tx.dsts, tx.selected_transfers, fake_outs_count, outs, unlock_time, needed_fee, extra,
|
||||
detail::digit_split_strategy, tx_dust_policy(::config::DEFAULT_DUST_THRESHOLD), test_tx, test_ptx);
|
||||
auto txBlob = t_serializable_object_to_blob(test_ptx.tx);
|
||||
needed_fee = calculate_fee(fee_per_kb, txBlob, fee_multiplier);
|
||||
|
@ -4260,10 +4280,10 @@ std::vector<wallet2::pending_tx> wallet2::create_transactions_2(std::vector<cryp
|
|||
LOG_PRINT_L2("We made a tx, adjusting fee and saving it");
|
||||
do {
|
||||
if (use_rct)
|
||||
transfer_selected_rct(tx.dsts, tx.selected_transfers, fake_outs_count, unlock_time, needed_fee, extra,
|
||||
transfer_selected_rct(tx.dsts, tx.selected_transfers, fake_outs_count, outs, unlock_time, needed_fee, extra,
|
||||
test_tx, test_ptx);
|
||||
else
|
||||
transfer_selected(tx.dsts, tx.selected_transfers, fake_outs_count, unlock_time, needed_fee, extra,
|
||||
transfer_selected(tx.dsts, tx.selected_transfers, fake_outs_count, outs, unlock_time, needed_fee, extra,
|
||||
detail::digit_split_strategy, tx_dust_policy(::config::DEFAULT_DUST_THRESHOLD), test_tx, test_ptx);
|
||||
txBlob = t_serializable_object_to_blob(test_ptx.tx);
|
||||
needed_fee = calculate_fee(fee_per_kb, txBlob, fee_multiplier);
|
||||
|
@ -4351,6 +4371,7 @@ std::vector<wallet2::pending_tx> wallet2::create_transactions_from(const crypton
|
|||
std::vector<TX> txes;
|
||||
uint64_t needed_fee, available_for_fee = 0;
|
||||
uint64_t upper_transaction_size_limit = get_upper_tranaction_size_limit();
|
||||
std::vector<std::vector<get_outs_entry>> outs;
|
||||
|
||||
const bool use_rct = fake_outs_count > 0 && use_fork_rules(4, 0);
|
||||
const bool use_new_fee = use_fork_rules(3, -720 * 14);
|
||||
|
@ -4386,6 +4407,9 @@ std::vector<wallet2::pending_tx> wallet2::create_transactions_from(const crypton
|
|||
uint64_t available_amount = td.amount();
|
||||
accumulated_outputs += available_amount;
|
||||
|
||||
// clear any fake outs we'd already gathered, since we'll need a new set
|
||||
outs.clear();
|
||||
|
||||
// here, check if we need to sent tx and start a new one
|
||||
LOG_PRINT_L2("Considering whether to create a tx now, " << tx.selected_transfers.size() << " inputs, tx limit "
|
||||
<< upper_transaction_size_limit);
|
||||
|
@ -4407,10 +4431,10 @@ std::vector<wallet2::pending_tx> wallet2::create_transactions_from(const crypton
|
|||
LOG_PRINT_L2("Trying to create a tx now, with " << tx.dsts.size() << " destinations and " <<
|
||||
tx.selected_transfers.size() << " outputs");
|
||||
if (use_rct)
|
||||
transfer_selected_rct(tx.dsts, tx.selected_transfers, fake_outs_count, unlock_time, needed_fee, extra,
|
||||
transfer_selected_rct(tx.dsts, tx.selected_transfers, fake_outs_count, outs, unlock_time, needed_fee, extra,
|
||||
test_tx, test_ptx);
|
||||
else
|
||||
transfer_selected(tx.dsts, tx.selected_transfers, fake_outs_count, unlock_time, needed_fee, extra,
|
||||
transfer_selected(tx.dsts, tx.selected_transfers, fake_outs_count, outs, unlock_time, needed_fee, extra,
|
||||
detail::digit_split_strategy, tx_dust_policy(::config::DEFAULT_DUST_THRESHOLD), test_tx, test_ptx);
|
||||
auto txBlob = t_serializable_object_to_blob(test_ptx.tx);
|
||||
needed_fee = calculate_fee(fee_per_kb, txBlob, fee_multiplier);
|
||||
|
@ -4424,10 +4448,10 @@ std::vector<wallet2::pending_tx> wallet2::create_transactions_from(const crypton
|
|||
LOG_PRINT_L2("We made a tx, adjusting fee and saving it");
|
||||
tx.dsts[0].amount = available_for_fee - needed_fee;
|
||||
if (use_rct)
|
||||
transfer_selected_rct(tx.dsts, tx.selected_transfers, fake_outs_count, unlock_time, needed_fee, extra,
|
||||
transfer_selected_rct(tx.dsts, tx.selected_transfers, fake_outs_count, outs, unlock_time, needed_fee, extra,
|
||||
test_tx, test_ptx);
|
||||
else
|
||||
transfer_selected(tx.dsts, tx.selected_transfers, fake_outs_count, unlock_time, needed_fee, extra,
|
||||
transfer_selected(tx.dsts, tx.selected_transfers, fake_outs_count, outs, unlock_time, needed_fee, extra,
|
||||
detail::digit_split_strategy, tx_dust_policy(::config::DEFAULT_DUST_THRESHOLD), test_tx, test_ptx);
|
||||
txBlob = t_serializable_object_to_blob(test_ptx.tx);
|
||||
needed_fee = calculate_fee(fee_per_kb, txBlob, fee_multiplier);
|
||||
|
|
|
@ -275,6 +275,8 @@ namespace tools
|
|||
std::string m_description;
|
||||
};
|
||||
|
||||
typedef std::tuple<uint64_t, crypto::public_key, rct::key> get_outs_entry;
|
||||
|
||||
/*!
|
||||
* \brief Generates a wallet or restores one.
|
||||
* \param wallet_ Name of wallet file
|
||||
|
@ -387,8 +389,10 @@ namespace tools
|
|||
void transfer(const std::vector<cryptonote::tx_destination_entry>& dsts, const size_t fake_outputs_count, const std::vector<size_t> &unused_transfers_indices, uint64_t unlock_time, uint64_t fee, const std::vector<uint8_t>& extra, cryptonote::transaction& tx, pending_tx& ptx, bool trusted_daemon);
|
||||
template<typename T>
|
||||
void transfer_selected(const std::vector<cryptonote::tx_destination_entry>& dsts, const std::list<size_t> selected_transfers, size_t fake_outputs_count,
|
||||
std::vector<std::vector<tools::wallet2::get_outs_entry>> &outs,
|
||||
uint64_t unlock_time, uint64_t fee, const std::vector<uint8_t>& extra, T destination_split_strategy, const tx_dust_policy& dust_policy, cryptonote::transaction& tx, pending_tx &ptx);
|
||||
void transfer_selected_rct(std::vector<cryptonote::tx_destination_entry> dsts, const std::list<size_t> selected_transfers, size_t fake_outputs_count,
|
||||
std::vector<std::vector<tools::wallet2::get_outs_entry>> &outs,
|
||||
uint64_t unlock_time, uint64_t fee, const std::vector<uint8_t>& extra, cryptonote::transaction& tx, pending_tx &ptx);
|
||||
|
||||
void commit_tx(pending_tx& ptx_vector);
|
||||
|
@ -607,8 +611,7 @@ namespace tools
|
|||
std::vector<size_t> pick_preferred_rct_inputs(uint64_t needed_money) const;
|
||||
void set_spent(size_t idx, uint64_t height);
|
||||
void set_unspent(size_t idx);
|
||||
template<typename entry>
|
||||
void get_outs(std::vector<std::vector<entry>> &outs, const std::list<size_t> &selected_transfers, size_t fake_outputs_count);
|
||||
void get_outs(std::vector<std::vector<get_outs_entry>> &outs, const std::list<size_t> &selected_transfers, size_t fake_outputs_count);
|
||||
bool wallet_generate_key_image_helper(const cryptonote::account_keys& ack, const crypto::public_key& tx_public_key, size_t real_output_index, cryptonote::keypair& in_ephemeral, crypto::key_image& ki);
|
||||
crypto::public_key get_tx_pub_key_from_received_outs(const tools::wallet2::transfer_details &td) const;
|
||||
|
||||
|
|
|
@ -396,6 +396,7 @@ namespace tools
|
|||
std::vector<cryptonote::tx_destination_entry> dsts;
|
||||
std::vector<uint8_t> extra;
|
||||
|
||||
LOG_PRINT_L3("on_transfer_split starts");
|
||||
if (m_wallet.restricted())
|
||||
{
|
||||
er.code = WALLET_RPC_ERROR_CODE_DENIED;
|
||||
|
@ -485,9 +486,13 @@ namespace tools
|
|||
mixin = 2;
|
||||
}
|
||||
std::vector<wallet2::pending_tx> ptx_vector;
|
||||
LOG_PRINT_L2("on_transfer_split calling create_transactions_2");
|
||||
ptx_vector = m_wallet.create_transactions_2(dsts, mixin, req.unlock_time, req.priority, extra, req.trusted_daemon);
|
||||
LOG_PRINT_L2("on_transfer_split called create_transactions_2");
|
||||
|
||||
LOG_PRINT_L2("on_transfer_split calling commit_txyy");
|
||||
m_wallet.commit_tx(ptx_vector);
|
||||
LOG_PRINT_L2("on_transfer_split called commit_txyy");
|
||||
|
||||
// populate response with tx hashes
|
||||
for (auto & ptx : ptx_vector)
|
||||
|
|
Loading…
Reference in a new issue