mirror of
https://github.com/basicswap/basicswap.git
synced 2025-12-29 08:51:37 +01:00
Fix: Rate tolerance. (#339)
* Fix: Rate tolerance. * Fix GUI Rate tolerance. * Fix: json/rate * Fix: Mismatch * Fix: Use backend handle calc. * Cleanup * Fix: format_amount * Add test.
This commit is contained in:
@@ -2282,6 +2282,13 @@ class BasicSwap(BaseApp, UIApp):
|
|||||||
if valid_for_seconds > 24 * 60 * 60:
|
if valid_for_seconds > 24 * 60 * 60:
|
||||||
raise ValueError("Bid TTL too high")
|
raise ValueError("Bid TTL too high")
|
||||||
|
|
||||||
|
def calculateRateTolerance(self, offer_rate: int) -> int:
|
||||||
|
return max(1, offer_rate // 10000)
|
||||||
|
|
||||||
|
def ratesMatch(self, rate1: int, rate2: int, offer_rate: int) -> bool:
|
||||||
|
tolerance = self.calculateRateTolerance(offer_rate)
|
||||||
|
return abs(rate1 - rate2) <= tolerance
|
||||||
|
|
||||||
def validateBidAmount(self, offer, bid_amount: int, bid_rate: int) -> None:
|
def validateBidAmount(self, offer, bid_amount: int, bid_rate: int) -> None:
|
||||||
ensure(bid_amount >= offer.min_bid_amount, "Bid amount below minimum")
|
ensure(bid_amount >= offer.min_bid_amount, "Bid amount below minimum")
|
||||||
ensure(bid_amount <= offer.amount_from, "Bid amount above offer amount")
|
ensure(bid_amount <= offer.amount_from, "Bid amount above offer amount")
|
||||||
@@ -2290,7 +2297,10 @@ class BasicSwap(BaseApp, UIApp):
|
|||||||
offer.amount_from == bid_amount, "Bid amount must match offer amount."
|
offer.amount_from == bid_amount, "Bid amount must match offer amount."
|
||||||
)
|
)
|
||||||
if not offer.rate_negotiable:
|
if not offer.rate_negotiable:
|
||||||
ensure(offer.rate == bid_rate, "Bid rate must match offer rate.")
|
ensure(
|
||||||
|
self.ratesMatch(bid_rate, offer.rate, offer.rate),
|
||||||
|
"Rate mismatch.",
|
||||||
|
)
|
||||||
|
|
||||||
def ensureWalletCanSend(
|
def ensureWalletCanSend(
|
||||||
self, ci, swap_type, ensure_balance: int, estimated_fee: int, for_offer=True
|
self, ci, swap_type, ensure_balance: int, estimated_fee: int, for_offer=True
|
||||||
@@ -3337,12 +3347,18 @@ class BasicSwap(BaseApp, UIApp):
|
|||||||
bid_rate: int = ci_from.make_int(amount_to / amount, r=1)
|
bid_rate: int = ci_from.make_int(amount_to / amount, r=1)
|
||||||
|
|
||||||
if offer.amount_negotiable and not offer.rate_negotiable:
|
if offer.amount_negotiable and not offer.rate_negotiable:
|
||||||
if bid_rate != offer.rate and extra_options.get(
|
|
||||||
"adjust_amount_for_rate", True
|
if extra_options.get("adjust_amount_for_rate", True):
|
||||||
):
|
self.log.debug(
|
||||||
self.log.debug("Attempting to reduce amount to match offer rate.")
|
"Attempting to reduce amount to match offer rate within tolerance."
|
||||||
|
)
|
||||||
|
|
||||||
adjust_tries: int = 10000 if ci_from.exp() > 8 else 1000
|
adjust_tries: int = 10000 if ci_from.exp() > 8 else 1000
|
||||||
|
best_amount = amount
|
||||||
|
best_amount_to = amount_to
|
||||||
|
best_bid_rate = bid_rate
|
||||||
|
best_diff = abs(bid_rate - offer.rate)
|
||||||
|
|
||||||
for i in range(adjust_tries):
|
for i in range(adjust_tries):
|
||||||
test_amount = amount - i
|
test_amount = amount - i
|
||||||
test_amount_to: int = int(
|
test_amount_to: int = int(
|
||||||
@@ -3351,29 +3367,59 @@ class BasicSwap(BaseApp, UIApp):
|
|||||||
test_bid_rate: int = ci_from.make_int(
|
test_bid_rate: int = ci_from.make_int(
|
||||||
test_amount_to / test_amount, r=1
|
test_amount_to / test_amount, r=1
|
||||||
)
|
)
|
||||||
|
test_diff = abs(test_bid_rate - offer.rate)
|
||||||
|
|
||||||
if test_bid_rate != offer.rate:
|
if test_diff < best_diff and self.ratesMatch(
|
||||||
|
test_bid_rate, offer.rate, offer.rate
|
||||||
|
):
|
||||||
|
best_amount = test_amount
|
||||||
|
best_amount_to = test_amount_to
|
||||||
|
best_bid_rate = test_bid_rate
|
||||||
|
best_diff = test_diff
|
||||||
|
|
||||||
|
if test_diff == 0:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not self.ratesMatch(test_bid_rate, offer.rate, offer.rate):
|
||||||
test_amount_to -= 1
|
test_amount_to -= 1
|
||||||
test_bid_rate: int = ci_from.make_int(
|
test_bid_rate: int = ci_from.make_int(
|
||||||
test_amount_to / test_amount, r=1
|
test_amount_to / test_amount, r=1
|
||||||
)
|
)
|
||||||
|
test_diff = abs(test_bid_rate - offer.rate)
|
||||||
|
|
||||||
if test_bid_rate == offer.rate:
|
if test_diff < best_diff and self.ratesMatch(
|
||||||
if amount != test_amount:
|
test_bid_rate, offer.rate, offer.rate
|
||||||
|
):
|
||||||
|
best_amount = test_amount
|
||||||
|
best_amount_to = test_amount_to
|
||||||
|
best_bid_rate = test_bid_rate
|
||||||
|
best_diff = test_diff
|
||||||
|
|
||||||
|
if test_diff == 0:
|
||||||
|
break
|
||||||
|
|
||||||
|
if amount == test_amount and amount_to == test_amount_to:
|
||||||
|
break
|
||||||
|
|
||||||
|
if best_diff == abs(bid_rate - offer.rate):
|
||||||
|
best_amount = test_amount
|
||||||
|
best_amount_to = test_amount_to
|
||||||
|
best_bid_rate = test_bid_rate
|
||||||
|
break
|
||||||
|
if best_amount != amount or best_amount_to != amount_to:
|
||||||
|
if amount != best_amount:
|
||||||
msg: str = "Reducing bid amount-from"
|
msg: str = "Reducing bid amount-from"
|
||||||
if not self.log.safe_logs:
|
if not self.log.safe_logs:
|
||||||
msg += f" from {amount} to {test_amount} to match offer rate."
|
msg += f" from {amount} to {best_amount} to match offer rate (diff: {best_diff})."
|
||||||
self.log.info(msg)
|
self.log.info(msg)
|
||||||
elif amount_to != test_amount_to:
|
elif amount_to != best_amount_to:
|
||||||
# Only show on first loop iteration (amount from unchanged)
|
|
||||||
msg: str = "Reducing bid amount-to"
|
msg: str = "Reducing bid amount-to"
|
||||||
if not self.log.safe_logs:
|
if not self.log.safe_logs:
|
||||||
msg += f" from {amount_to} to {test_amount_to} to match offer rate."
|
msg += f" from {amount_to} to {best_amount_to} to match offer rate (diff: {best_diff})."
|
||||||
self.log.info(msg)
|
self.log.info(msg)
|
||||||
amount = test_amount
|
amount = best_amount
|
||||||
amount_to = test_amount_to
|
amount_to = best_amount_to
|
||||||
bid_rate = test_bid_rate
|
bid_rate = best_bid_rate
|
||||||
break
|
|
||||||
return amount, amount_to, bid_rate
|
return amount, amount_to, bid_rate
|
||||||
|
|
||||||
def postBid(
|
def postBid(
|
||||||
@@ -7934,8 +7980,8 @@ class BasicSwap(BaseApp, UIApp):
|
|||||||
raise AutomationConstraint("Bid amount below offer minimum")
|
raise AutomationConstraint("Bid amount below offer minimum")
|
||||||
|
|
||||||
if opts.get("exact_rate_only", False) is True:
|
if opts.get("exact_rate_only", False) is True:
|
||||||
if bid_rate != offer.rate:
|
if not self.ratesMatch(bid_rate, offer.rate, offer.rate):
|
||||||
raise AutomationConstraint("Need exact rate match")
|
raise AutomationConstraint("Rate outside acceptable tolerance")
|
||||||
|
|
||||||
active_bids, total_bids_value = self.getCompletedAndActiveBidsValue(
|
active_bids, total_bids_value = self.getCompletedAndActiveBidsValue(
|
||||||
offer, use_cursor
|
offer, use_cursor
|
||||||
|
|||||||
@@ -510,7 +510,8 @@ def formatBids(swap_client, bids, filters) -> bytes:
|
|||||||
bid_rate: int = 0 if b[10] is None else b[10]
|
bid_rate: int = 0 if b[10] is None else b[10]
|
||||||
amount_to = None
|
amount_to = None
|
||||||
if ci_to:
|
if ci_to:
|
||||||
amount_to = ci_to.format_amount((b[4] * bid_rate) // ci_from.COIN())
|
amount_to_int = (b[4] * bid_rate + ci_from.COIN() - 1) // ci_from.COIN()
|
||||||
|
amount_to = ci_to.format_amount(amount_to_int)
|
||||||
|
|
||||||
bid_data = {
|
bid_data = {
|
||||||
"bid_id": b[2].hex(),
|
"bid_id": b[2].hex(),
|
||||||
@@ -800,16 +801,14 @@ def js_rate(self, url_split, post_string, is_json) -> bytes:
|
|||||||
if amt_from_str is not None:
|
if amt_from_str is not None:
|
||||||
rate = ci_to.make_int(rate, r=1)
|
rate = ci_to.make_int(rate, r=1)
|
||||||
amt_from = inputAmount(amt_from_str, ci_from)
|
amt_from = inputAmount(amt_from_str, ci_from)
|
||||||
amount_to = ci_to.format_amount(
|
amount_to_int = (amt_from * rate + ci_from.COIN() - 1) // ci_from.COIN()
|
||||||
int((amt_from * rate) // ci_from.COIN()), r=1
|
amount_to = ci_to.format_amount(amount_to_int)
|
||||||
)
|
|
||||||
return bytes(json.dumps({"amount_to": amount_to}), "UTF-8")
|
return bytes(json.dumps({"amount_to": amount_to}), "UTF-8")
|
||||||
if amt_to_str is not None:
|
if amt_to_str is not None:
|
||||||
rate = ci_from.make_int(1.0 / float(rate), r=1)
|
rate = ci_from.make_int(1.0 / float(rate), r=1)
|
||||||
amt_to = inputAmount(amt_to_str, ci_to)
|
amt_to = inputAmount(amt_to_str, ci_to)
|
||||||
amount_from = ci_from.format_amount(
|
amount_from_int = (amt_to * rate + ci_to.COIN() - 1) // ci_to.COIN()
|
||||||
int((amt_to * rate) // ci_to.COIN()), r=1
|
amount_from = ci_from.format_amount(amount_from_int)
|
||||||
)
|
|
||||||
return bytes(json.dumps({"amount_from": amount_from}), "UTF-8")
|
return bytes(json.dumps({"amount_from": amount_from}), "UTF-8")
|
||||||
|
|
||||||
amt_from: int = inputAmount(get_data_entry(post_data, "amt_from"), ci_from)
|
amt_from: int = inputAmount(get_data_entry(post_data, "amt_from"), ci_from)
|
||||||
|
|||||||
@@ -775,9 +775,16 @@ function resetForm() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function roundUpToDecimals(value, decimals) {
|
||||||
|
const factor = Math.pow(10, decimals);
|
||||||
|
return Math.ceil(value * factor) / factor;
|
||||||
|
}
|
||||||
|
|
||||||
function updateBidParams(value_changed) {
|
function updateBidParams(value_changed) {
|
||||||
const coin_from = document.getElementById('coin_from')?.value;
|
const coin_from = document.getElementById('coin_from')?.value;
|
||||||
const coin_to = document.getElementById('coin_to')?.value;
|
const coin_to = document.getElementById('coin_to')?.value;
|
||||||
|
const coin_from_exp = parseInt(document.getElementById('coin_from_exp')?.value || '8');
|
||||||
|
const coin_to_exp = parseInt(document.getElementById('coin_to_exp')?.value || '8');
|
||||||
const amt_var = document.getElementById('amt_var')?.value;
|
const amt_var = document.getElementById('amt_var')?.value;
|
||||||
const rate_var = document.getElementById('rate_var')?.value;
|
const rate_var = document.getElementById('rate_var')?.value;
|
||||||
const bidAmountInput = document.getElementById('bid_amount');
|
const bidAmountInput = document.getElementById('bid_amount');
|
||||||
@@ -797,19 +804,19 @@ function updateBidParams(value_changed) {
|
|||||||
if (value_changed === 'rate') {
|
if (value_changed === 'rate') {
|
||||||
if (bidAmountSendInput && bidAmountInput) {
|
if (bidAmountSendInput && bidAmountInput) {
|
||||||
const sendAmount = parseFloat(bidAmountSendInput.value) || 0;
|
const sendAmount = parseFloat(bidAmountSendInput.value) || 0;
|
||||||
const receiveAmount = (sendAmount / rate).toFixed(8);
|
const receiveAmount = (sendAmount / rate).toFixed(coin_from_exp);
|
||||||
bidAmountInput.value = receiveAmount;
|
bidAmountInput.value = receiveAmount;
|
||||||
}
|
}
|
||||||
} else if (value_changed === 'sending') {
|
} else if (value_changed === 'sending') {
|
||||||
if (bidAmountSendInput && bidAmountInput) {
|
if (bidAmountSendInput && bidAmountInput) {
|
||||||
const sendAmount = parseFloat(bidAmountSendInput.value) || 0;
|
const sendAmount = parseFloat(bidAmountSendInput.value) || 0;
|
||||||
const receiveAmount = (sendAmount / rate).toFixed(8);
|
const receiveAmount = (sendAmount / rate).toFixed(coin_from_exp);
|
||||||
bidAmountInput.value = receiveAmount;
|
bidAmountInput.value = receiveAmount;
|
||||||
}
|
}
|
||||||
} else if (value_changed === 'receiving') {
|
} else if (value_changed === 'receiving') {
|
||||||
if (bidAmountInput && bidAmountSendInput) {
|
if (bidAmountInput && bidAmountSendInput) {
|
||||||
const receiveAmount = parseFloat(bidAmountInput.value) || 0;
|
const receiveAmount = parseFloat(bidAmountInput.value) || 0;
|
||||||
const sendAmount = (receiveAmount * rate).toFixed(8);
|
const sendAmount = roundUpToDecimals(receiveAmount * rate, coin_to_exp).toFixed(coin_to_exp);
|
||||||
bidAmountSendInput.value = sendAmount;
|
bidAmountSendInput.value = sendAmount;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1084,6 +1091,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
<input type="hidden" name="confirm" value="true">
|
<input type="hidden" name="confirm" value="true">
|
||||||
<input type="hidden" id="coin_from" value="{{ data.coin_from_ind }}">
|
<input type="hidden" id="coin_from" value="{{ data.coin_from_ind }}">
|
||||||
<input type="hidden" id="coin_to" value="{{ data.coin_to_ind }}">
|
<input type="hidden" id="coin_to" value="{{ data.coin_to_ind }}">
|
||||||
|
<input type="hidden" id="coin_from_exp" value="{{ data.coin_from_exp }}">
|
||||||
|
<input type="hidden" id="coin_to_exp" value="{{ data.coin_to_exp }}">
|
||||||
<input type="hidden" id="amt_var" value="{{ data.amount_negotiable }}">
|
<input type="hidden" id="amt_var" value="{{ data.amount_negotiable }}">
|
||||||
<input type="hidden" id="rate_var" value="{{ data.rate_negotiable }}">
|
<input type="hidden" id="rate_var" value="{{ data.rate_negotiable }}">
|
||||||
<input type="hidden" id="amount_from" value="{{ data.amt_from }}">
|
<input type="hidden" id="amount_from" value="{{ data.amt_from }}">
|
||||||
|
|||||||
@@ -707,6 +707,8 @@ def page_offer(self, url_split, post_string):
|
|||||||
"coin_to": ci_to.coin_name(),
|
"coin_to": ci_to.coin_name(),
|
||||||
"coin_from_ind": int(ci_from.coin_type()),
|
"coin_from_ind": int(ci_from.coin_type()),
|
||||||
"coin_to_ind": int(ci_to.coin_type()),
|
"coin_to_ind": int(ci_to.coin_type()),
|
||||||
|
"coin_from_exp": ci_from.exp(),
|
||||||
|
"coin_to_exp": ci_to.exp(),
|
||||||
"amt_from": ci_from.format_amount(offer.amount_from),
|
"amt_from": ci_from.format_amount(offer.amount_from),
|
||||||
"amt_to": ci_to.format_amount(amount_to),
|
"amt_to": ci_to.format_amount(amount_to),
|
||||||
"amt_bid_min": ci_from.format_amount(offer.min_bid_amount),
|
"amt_bid_min": ci_from.format_amount(offer.min_bid_amount),
|
||||||
|
|||||||
@@ -299,6 +299,82 @@ class Test(unittest.TestCase):
|
|||||||
rate_check = make_int((amount_to / amount_from), scale_from)
|
rate_check = make_int((amount_to / amount_from), scale_from)
|
||||||
assert rate == rate_check
|
assert rate == rate_check
|
||||||
|
|
||||||
|
def test_rate_tolerance_precision(self):
|
||||||
|
scale = 8
|
||||||
|
amount_from = make_int("0.001", scale)
|
||||||
|
offer_rate = make_int("0.354185354480", scale, r=1)
|
||||||
|
amount_to = int((amount_from * offer_rate) // (10**scale))
|
||||||
|
bid_rate = make_int(amount_to / amount_from, r=1)
|
||||||
|
|
||||||
|
rate_tolerance = max(1, offer_rate // 10000)
|
||||||
|
rate_diff = abs(bid_rate - offer_rate)
|
||||||
|
assert (
|
||||||
|
rate_diff <= rate_tolerance
|
||||||
|
), f"Rate difference {rate_diff} exceeds tolerance {rate_tolerance}"
|
||||||
|
|
||||||
|
test_cases = [
|
||||||
|
("0.001", "0.123456789"),
|
||||||
|
("0.5", "1.23456789"),
|
||||||
|
("0.00001", "999.99999999"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for amount_str, rate_str in test_cases:
|
||||||
|
amount_from = make_int(amount_str, scale)
|
||||||
|
offer_rate = make_int(rate_str, scale, r=1)
|
||||||
|
amount_to = int((amount_from * offer_rate) // (10**scale))
|
||||||
|
bid_rate = make_int(amount_to / amount_from, r=1)
|
||||||
|
|
||||||
|
rate_tolerance = max(1, offer_rate // 10000)
|
||||||
|
rate_diff = abs(bid_rate - offer_rate)
|
||||||
|
|
||||||
|
assert rate_diff <= rate_tolerance, (
|
||||||
|
f"Rate difference {rate_diff} exceeds tolerance {rate_tolerance} "
|
||||||
|
f"for amount {amount_str} at rate {rate_str}"
|
||||||
|
)
|
||||||
|
|
||||||
|
large_offer_rate = make_int("1.0", scale)
|
||||||
|
large_tolerance = max(1, large_offer_rate // 10000)
|
||||||
|
bad_bid_rate = large_offer_rate + large_tolerance + 1
|
||||||
|
rate_diff = abs(bad_bid_rate - large_offer_rate)
|
||||||
|
assert (
|
||||||
|
rate_diff > large_tolerance
|
||||||
|
), "Test setup error: difference should exceed tolerance"
|
||||||
|
|
||||||
|
def test_rate_tolerance_helper_functions(self):
|
||||||
|
class MockBasicSwap:
|
||||||
|
def calculateRateTolerance(self, offer_rate: int) -> int:
|
||||||
|
return max(1, offer_rate // 10000)
|
||||||
|
|
||||||
|
def ratesMatch(self, rate1: int, rate2: int, offer_rate: int) -> bool:
|
||||||
|
tolerance = self.calculateRateTolerance(offer_rate)
|
||||||
|
return abs(rate1 - rate2) <= tolerance
|
||||||
|
|
||||||
|
mock_swap = MockBasicSwap()
|
||||||
|
|
||||||
|
assert mock_swap.calculateRateTolerance(100000000) == 10000
|
||||||
|
assert mock_swap.calculateRateTolerance(1000000) == 100
|
||||||
|
assert mock_swap.calculateRateTolerance(100) == 1
|
||||||
|
assert mock_swap.calculateRateTolerance(50) == 1
|
||||||
|
|
||||||
|
offer_rate = 100000000
|
||||||
|
tolerance = 10000
|
||||||
|
|
||||||
|
assert mock_swap.ratesMatch(offer_rate, offer_rate, offer_rate)
|
||||||
|
assert mock_swap.ratesMatch(offer_rate, offer_rate + tolerance, offer_rate)
|
||||||
|
assert mock_swap.ratesMatch(offer_rate, offer_rate - tolerance, offer_rate)
|
||||||
|
assert mock_swap.ratesMatch(offer_rate + tolerance // 2, offer_rate, offer_rate)
|
||||||
|
|
||||||
|
assert not mock_swap.ratesMatch(
|
||||||
|
offer_rate, offer_rate + tolerance + 1, offer_rate
|
||||||
|
)
|
||||||
|
assert not mock_swap.ratesMatch(
|
||||||
|
offer_rate, offer_rate - tolerance - 1, offer_rate
|
||||||
|
)
|
||||||
|
|
||||||
|
small_rate = 1000
|
||||||
|
assert mock_swap.ratesMatch(small_rate, small_rate + 1, small_rate)
|
||||||
|
assert not mock_swap.ratesMatch(small_rate, small_rate + 2, small_rate)
|
||||||
|
|
||||||
scale_from = 8
|
scale_from = 8
|
||||||
scale_to = 8
|
scale_to = 8
|
||||||
amount_from = make_int(0.073, scale_from)
|
amount_from = make_int(0.073, scale_from)
|
||||||
|
|||||||
Reference in New Issue
Block a user