From 02ceb89d14ae37fc4d4f7ab6582dd93065c2f387 Mon Sep 17 00:00:00 2001 From: Gerlof van Ek Date: Mon, 28 Jul 2025 21:43:06 +0200 Subject: [PATCH] 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. --- basicswap/basicswap.py | 92 +++++++++++++++++++++++++--------- basicswap/js_server.py | 13 +++-- basicswap/templates/offer.html | 15 ++++-- basicswap/ui/page_offers.py | 2 + tests/basicswap/test_other.py | 76 ++++++++++++++++++++++++++++ 5 files changed, 165 insertions(+), 33 deletions(-) diff --git a/basicswap/basicswap.py b/basicswap/basicswap.py index 7b03eb7..7090a6d 100644 --- a/basicswap/basicswap.py +++ b/basicswap/basicswap.py @@ -2282,6 +2282,13 @@ class BasicSwap(BaseApp, UIApp): if valid_for_seconds > 24 * 60 * 60: 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: ensure(bid_amount >= offer.min_bid_amount, "Bid amount below minimum") 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." ) 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( 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) if offer.amount_negotiable and not offer.rate_negotiable: - if bid_rate != offer.rate and extra_options.get( - "adjust_amount_for_rate", True - ): - self.log.debug("Attempting to reduce amount to match offer rate.") + + if extra_options.get("adjust_amount_for_rate", True): + self.log.debug( + "Attempting to reduce amount to match offer rate within tolerance." + ) 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): test_amount = amount - i test_amount_to: int = int( @@ -3351,29 +3367,59 @@ class BasicSwap(BaseApp, UIApp): test_bid_rate: int = ci_from.make_int( 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_bid_rate: int = ci_from.make_int( test_amount_to / test_amount, r=1 ) + test_diff = abs(test_bid_rate - offer.rate) - if test_bid_rate == offer.rate: - if amount != test_amount: - msg: str = "Reducing bid amount-from" - if not self.log.safe_logs: - msg += f" from {amount} to {test_amount} to match offer rate." - self.log.info(msg) - elif amount_to != test_amount_to: - # Only show on first loop iteration (amount from unchanged) - msg: str = "Reducing bid amount-to" - if not self.log.safe_logs: - msg += f" from {amount_to} to {test_amount_to} to match offer rate." - self.log.info(msg) - amount = test_amount - amount_to = test_amount_to - bid_rate = test_bid_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 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" + if not self.log.safe_logs: + msg += f" from {amount} to {best_amount} to match offer rate (diff: {best_diff})." + self.log.info(msg) + elif amount_to != best_amount_to: + msg: str = "Reducing bid amount-to" + if not self.log.safe_logs: + msg += f" from {amount_to} to {best_amount_to} to match offer rate (diff: {best_diff})." + self.log.info(msg) + amount = best_amount + amount_to = best_amount_to + bid_rate = best_bid_rate return amount, amount_to, bid_rate def postBid( @@ -7934,8 +7980,8 @@ class BasicSwap(BaseApp, UIApp): raise AutomationConstraint("Bid amount below offer minimum") if opts.get("exact_rate_only", False) is True: - if bid_rate != offer.rate: - raise AutomationConstraint("Need exact rate match") + if not self.ratesMatch(bid_rate, offer.rate, offer.rate): + raise AutomationConstraint("Rate outside acceptable tolerance") active_bids, total_bids_value = self.getCompletedAndActiveBidsValue( offer, use_cursor diff --git a/basicswap/js_server.py b/basicswap/js_server.py index d8e3273..efd1031 100644 --- a/basicswap/js_server.py +++ b/basicswap/js_server.py @@ -510,7 +510,8 @@ def formatBids(swap_client, bids, filters) -> bytes: bid_rate: int = 0 if b[10] is None else b[10] amount_to = None 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_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: rate = ci_to.make_int(rate, r=1) amt_from = inputAmount(amt_from_str, ci_from) - amount_to = ci_to.format_amount( - int((amt_from * rate) // ci_from.COIN()), r=1 - ) + amount_to_int = (amt_from * rate + ci_from.COIN() - 1) // ci_from.COIN() + amount_to = ci_to.format_amount(amount_to_int) return bytes(json.dumps({"amount_to": amount_to}), "UTF-8") if amt_to_str is not None: rate = ci_from.make_int(1.0 / float(rate), r=1) amt_to = inputAmount(amt_to_str, ci_to) - amount_from = ci_from.format_amount( - int((amt_to * rate) // ci_to.COIN()), r=1 - ) + amount_from_int = (amt_to * rate + ci_to.COIN() - 1) // ci_to.COIN() + amount_from = ci_from.format_amount(amount_from_int) return bytes(json.dumps({"amount_from": amount_from}), "UTF-8") amt_from: int = inputAmount(get_data_entry(post_data, "amt_from"), ci_from) diff --git a/basicswap/templates/offer.html b/basicswap/templates/offer.html index 5114e53..b5e1c11 100644 --- a/basicswap/templates/offer.html +++ b/basicswap/templates/offer.html @@ -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) { const coin_from = document.getElementById('coin_from')?.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 rate_var = document.getElementById('rate_var')?.value; const bidAmountInput = document.getElementById('bid_amount'); @@ -797,19 +804,19 @@ function updateBidParams(value_changed) { if (value_changed === 'rate') { if (bidAmountSendInput && bidAmountInput) { const sendAmount = parseFloat(bidAmountSendInput.value) || 0; - const receiveAmount = (sendAmount / rate).toFixed(8); + const receiveAmount = (sendAmount / rate).toFixed(coin_from_exp); bidAmountInput.value = receiveAmount; } } else if (value_changed === 'sending') { if (bidAmountSendInput && bidAmountInput) { const sendAmount = parseFloat(bidAmountSendInput.value) || 0; - const receiveAmount = (sendAmount / rate).toFixed(8); + const receiveAmount = (sendAmount / rate).toFixed(coin_from_exp); bidAmountInput.value = receiveAmount; } } else if (value_changed === 'receiving') { if (bidAmountInput && bidAmountSendInput) { 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; } } @@ -1084,6 +1091,8 @@ document.addEventListener('DOMContentLoaded', function() { + + diff --git a/basicswap/ui/page_offers.py b/basicswap/ui/page_offers.py index a27e839..b99ff95 100644 --- a/basicswap/ui/page_offers.py +++ b/basicswap/ui/page_offers.py @@ -707,6 +707,8 @@ def page_offer(self, url_split, post_string): "coin_to": ci_to.coin_name(), "coin_from_ind": int(ci_from.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_to": ci_to.format_amount(amount_to), "amt_bid_min": ci_from.format_amount(offer.min_bid_amount), diff --git a/tests/basicswap/test_other.py b/tests/basicswap/test_other.py index 0c79253..5229b0f 100644 --- a/tests/basicswap/test_other.py +++ b/tests/basicswap/test_other.py @@ -299,6 +299,82 @@ class Test(unittest.TestCase): rate_check = make_int((amount_to / amount_from), scale_from) 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_to = 8 amount_from = make_int(0.073, scale_from)