From 04e2020ff30b1ed3e0157e99b8af9d075c3a7cd2 Mon Sep 17 00:00:00 2001 From: tecnovert Date: Wed, 3 Jun 2026 23:43:30 +0200 Subject: [PATCH] feat: add subfee bids --- basicswap/basicswap.py | 204 ++++++++++++++---- basicswap/basicswap_util.py | 2 + basicswap/http_server.py | 2 +- basicswap/interface/bch.py | 11 +- basicswap/interface/btc.py | 71 +++++- basicswap/interface/dcr/dcr.py | 11 +- basicswap/interface/firo.py | 7 +- basicswap/interface/nav.py | 15 +- basicswap/interface/part.py | 11 +- basicswap/interface/pivx.py | 7 +- basicswap/js_server.py | 27 +++ basicswap/protocols/__init__.py | 7 +- basicswap/protocols/atomic_swap_1.py | 8 +- basicswap/protocols/xmr_swap_1.py | 71 +++++- basicswap/static/js/modules/event-handlers.js | 61 +++++- basicswap/static/js/pages/offer-page.js | 61 +++++- basicswap/templates/offer.html | 13 ++ basicswap/ui/page_offers.py | 41 +++- doc/release-notes.md | 5 +- tests/basicswap/extended/test_pivx.py | 2 +- tests/basicswap/test_btc_xmr.py | 167 +++++++++++++- tests/basicswap/test_other.py | 2 +- 22 files changed, 718 insertions(+), 88 deletions(-) diff --git a/basicswap/basicswap.py b/basicswap/basicswap.py index 5dd1ccf..d648ec0 100644 --- a/basicswap/basicswap.py +++ b/basicswap/basicswap.py @@ -5199,6 +5199,63 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): bid_rate = best_bid_rate return amount, amount_to, bid_rate + def createSubfeeBidTx(self, offer_id: bytes, amount_to: int, rate: int) -> dict: + self.log.debug( + f"createSubfeeBidTx for offer: {self.log.id(offer_id)}, amount to: {amount_to}" + ) + + offer, xmr_offer = self.getXmrOffer(offer_id) + ensure(offer, f"Offer not found: {self.log.id(offer_id)}.") + ensure(xmr_offer, f"Adaptor-sig offer not found: {self.log.id(offer_id)}.") + ensure( + offer.amount_negotiable, + f"Offer amounts are final: {self.log.id(offer_id)}.", + ) + if offer.coin_to in (Coins.XMR, Coins.WOW): + raise ValueError("TODO") + if offer.swap_type != SwapTypes.XMR_SWAP: + raise ValueError("TODO") + ci_to = self.ci(offer.coin_to) + ci_from = self.ci(offer.coin_from) + pi = self.pi(SwapTypes.XMR_SWAP) + reverse_bid: bool = self.is_reverse_ads_bid(offer.coin_from, offer.coin_to) + + feerate: int = xmr_offer.b_fee_rate + if reverse_bid: + # Create ITX + lock_txa: bytes = pi.getFundedInitiateTxTemplate( + ci_to, amount_to, sub_fee=True, feerate=feerate + ) + tx_obj = ci_to.loadTx(lock_txa, allow_witness=False) + lock_vout: int = pi.getMockITxSwapVout(ci_to, tx_obj) + amount_to_out: int = tx_obj.vout[lock_vout].nValue + else: + # Create PTX + mock_pk: bytes = pi.getMockPubkey(ci_to) + lock_txb: bytes = ci_to.createBLockTx(mock_pk, amount_to) + lock_txb = ci_to.fundTx(lock_txb, feerate, lock_unspents=False, subfee=True) + tx_obj = ci_to.loadTx(lock_txb, allow_witness=False) + lock_vout: int = pi.getMockPTxSwapVout(ci_to, tx_obj) + amount_to_out: int = tx_obj.vout[lock_vout].nValue + + amount_from_out: int = (amount_to_out * (10 ** ci_from.exp())) // rate + extra_options = {"bid_rate": rate} + amount_adjusted, amount_to_adjusted, bid_rate = self.setBidAmounts( + amount_from_out, offer, extra_options, ci_from + ) + if amount_to_adjusted < amount_to_out: + tx_obj.vout[lock_vout].nValue = amount_to_adjusted + + self.log.debug( + f"Amounts after subfee: to {ci_to.format_amount(amount_to_adjusted)} {ci_to.ticker()}, from {ci_from.format_amount(amount_to_adjusted)} {ci_from.ticker()}" + ) + tx_data: bytes = tx_obj.serialize_without_witness() + return { + "amount_from": amount_adjusted, + "amount_to": amount_to_adjusted, + "bid_tx": tx_data, + } + def postBid( self, offer_id: bytes, amount: int, addr_send_from: str = None, extra_options={} ) -> bytes: @@ -6016,46 +6073,66 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): "Incompatible offer protocol version", ) ensure(offer.expire_at > self.getTime(), "Offer has expired") + if offer.swap_type != SwapTypes.XMR_SWAP: + raise ValueError(f"TODO: Unknown swap type {offer.swap_type.name}") coin_from = Coins(offer.coin_from) coin_to = Coins(offer.coin_to) ci_from = self.ci(coin_from) ci_to = self.ci(coin_to) + reverse_bid: bool = self.is_reverse_ads_bid(coin_from, coin_to) - valid_for_seconds: int = extra_options.get("valid_for_seconds", 60 * 10) - - amount, amount_to, bid_rate = self.setBidAmounts( - amount, offer, extra_options, ci_from - ) - - bid_created_at: int = self.getTime() - if offer.swap_type != SwapTypes.XMR_SWAP: - raise ValueError(f"TODO: Unknown swap type {offer.swap_type.name}") + self.checkCoinsReady(coin_from, coin_to) ci_from.validateFeeRate(xmr_offer.a_fee_rate) ci_to.validateFeeRate(xmr_offer.b_fee_rate) + bid_created_at: int = self.getTime() + valid_for_seconds: int = extra_options.get("valid_for_seconds", 60 * 10) + + if "prefunded_tx" in extra_options: + pi = self.pi(SwapTypes.XMR_SWAP) + prefunded_tx_data: bytes = extra_options["prefunded_tx"] + if reverse_bid: + amount_to = pi.getMockITxSwapValue(ci_to, prefunded_tx_data) + else: + amount_to = pi.getMockPTxSwapValue(ci_to, prefunded_tx_data) + bid_rate: int = ci_from.make_int(amount_to / amount, r=1) + + prefunded_txid, prefunded_tx_fee_rate = ( + ci_to.validatePrefundedTxAmounts(prefunded_tx_data) + ) + self.log.debug(f"Using prefunded tx: {self.log.id(prefunded_txid)}") + ci_to.validateFeeRate(prefunded_tx_fee_rate) + else: + amount, amount_to, bid_rate = self.setBidAmounts( + amount, offer, extra_options, ci_from + ) + if not (self.debug and extra_options.get("debug_skip_validation", False)): self.validateBidValidTime( offer.swap_type, coin_from, coin_to, valid_for_seconds ) self.validateBidAmount(offer, amount, bid_rate) - self.checkCoinsReady(coin_from, coin_to) - # TODO: Better tx size estimate - fee_rate, fee_src = self.getFeeRateForCoin(coin_to, conf_target=2) - fee_rate_to = ci_to.make_int(fee_rate) + fee_rate_to = xmr_offer.b_fee_rate estimated_fee: int = fee_rate_to * ci_to.est_lock_tx_vsize() // 1000 - self.ensureWalletCanSend( - ci_to, offer.swap_type, int(amount_to), estimated_fee, for_offer=False - ) + + if "prefunded_tx" not in extra_options: + self.ensureWalletCanSend( + ci_to, + offer.swap_type, + int(amount_to), + estimated_fee, + for_offer=False, + ) bid_addr: str = self.prepareSMSGAddress( addr_send_from, AddressTypes.BID, cursor ) - # return id of route waiting to be established + # Return id of route waiting to be established request_data = { "offer_id": offer_id.hex(), "amount_from": amount, @@ -6073,7 +6150,6 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): valid_for_seconds, ) - reverse_bid: bool = self.is_reverse_ads_bid(coin_from, coin_to) if reverse_bid: reversed_rate: int = ci_to.make_int(amount / amount_to, r=1) @@ -6127,6 +6203,17 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): bid.bid_id = bid_id xmr_swap.bid_id = bid.bid_id + if "prefunded_tx" in extra_options: + prefunded_tx = PrefundedTx( + active_ind=1, + created_at=bid_created_at, + linked_type=Concepts.BID, + linked_id=bid.bid_id, + tx_type=TxTypes.ITX_PRE_FUNDED, + tx_data=extra_options["prefunded_tx"], + ) + self.add(prefunded_tx, cursor) + self.saveBidInSession(xmr_swap.bid_id, bid, cursor, xmr_swap) self.commitDB() @@ -6248,6 +6335,16 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): self.log.warning( f"Adaptor-sig swap restore height clamped to {wallet_restore_height}" ) + if "prefunded_tx" in extra_options: + prefunded_tx = PrefundedTx( + active_ind=1, + created_at=bid_created_at, + linked_type=Concepts.BID, + linked_id=bid.bid_id, + tx_type=TxTypes.PTX_PRE_FUNDED, + tx_data=extra_options["prefunded_tx"], + ) + self.add(prefunded_tx, cursor) self.saveBidInSession(bid.bid_id, bid, cursor, xmr_swap) self.log.info(f"Sent XMR_BID_FL {self.logIDB(xmr_swap.bid_id)}") @@ -6367,16 +6464,25 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): xmr_swap.a_lock_tx_script = pi.genScriptLockTxScript( ci_from, xmr_swap.pkal, xmr_swap.pkaf, **lockExtraArgs ) - prefunded_tx = self.getPreFundedTx( - Concepts.OFFER, - bid.offer_id, - TxTypes.ITX_PRE_FUNDED, - cursor=use_cursor, - ) + if reverse_bid and bid.was_sent: + prefunded_tx = self.getPreFundedTx( + Concepts.BID, + bid_id, + TxTypes.ITX_PRE_FUNDED, + cursor=use_cursor, + ) + else: + prefunded_tx = self.getPreFundedTx( + Concepts.OFFER, + bid.offer_id, + TxTypes.ITX_PRE_FUNDED, + cursor=use_cursor, + ) if prefunded_tx: xmr_swap.a_lock_tx = pi.promoteMockTx( ci_from, prefunded_tx, xmr_swap.a_lock_tx_script ) + self.log.info("Using pre-funded tx") else: xmr_swap.a_lock_tx = ci_from.createSCLockTx( bid.amount, xmr_swap.a_lock_tx_script, xmr_swap.vkbv @@ -6779,14 +6885,17 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): addr_to = ci.encodeScriptDest(p2wsh) else: addr_to = ci.encode_p2sh(initiate_script) - self.log.debug( - f"Create initiate txn for coin {ci.coin_name()} to {addr_to} for bid {self.log.id(bid_id)}" - ) if prefunded_tx: + self.log.debug( + f"Using pre-funded initiate txn for coin {ci.coin_name()} to {addr_to} for bid {self.log.id(bid_id)}" + ) pi = self.pi(SwapTypes.SELLER_FIRST) txn_signed = pi.promoteMockTx(ci, prefunded_tx, initiate_script).hex() else: + self.log.debug( + f"Create initiate txn for coin {ci.coin_name()} to {addr_to} for bid {self.log.id(bid_id)}" + ) txn_signed = ci.createRawSignedTransaction(addr_to, bid.amount) txjs = ci.describeTx(txn_signed) @@ -11607,19 +11716,38 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): cursor, ) + prefunded_tx = self.getPreFundedTx( + Concepts.BID, + bid.bid_id, + TxTypes.PTX_PRE_FUNDED, + cursor=cursor, + ) + try: b_lock_vout = 0 - result = ci_to.publishBLockTx( - xmr_swap.vkbv, - xmr_swap.pkbs, - bid.amount_to, - b_fee_rate, - unlock_time=unlock_time, - ) - if isinstance(result, tuple): - b_lock_tx_id, b_lock_vout = result + if prefunded_tx: + self.log.info("Using pre-funded tx") + pi = self.pi(offer.swap_type) + b_lock_tx = pi.promoteMockPTx( + ci_to, + prefunded_tx, + xmr_swap.vkbv, + xmr_swap.pkbs, + ) + b_lock_tx = ci_to.signTxWithWallet(b_lock_tx) + b_lock_tx_id = bytes.fromhex(ci_to.publishTx(b_lock_tx)) else: - b_lock_tx_id = result + result = ci_to.publishBLockTx( + xmr_swap.vkbv, + xmr_swap.pkbs, + bid.amount_to, + b_fee_rate, + unlock_time=unlock_time, + ) + if isinstance(result, tuple): + b_lock_tx_id, b_lock_vout = result + else: + b_lock_tx_id = result if bid.debug_ind == DebugTypes.B_LOCK_TX_MISSED_SEND: self.log.debug( f"Adaptor-sig bid {self.log.id(bid_id)}: Debug {bid.debug_ind} - Losing XMR lock tx {self.log.id(b_lock_tx_id)}." @@ -14170,7 +14298,7 @@ class BasicSwap(BaseApp, BSXNetwork, UIApp): def updateWalletsInfo( self, force_update: bool = False, - only_coin: bool = None, + only_coin: int = None, wait_for_complete: bool = False, ) -> None: now: int = self.getTime() diff --git a/basicswap/basicswap_util.py b/basicswap/basicswap_util.py index abd407d..31da65c 100644 --- a/basicswap/basicswap_util.py +++ b/basicswap/basicswap_util.py @@ -161,6 +161,8 @@ class TxTypes(IntEnum): BCH_MERCY = auto() + PTX_PRE_FUNDED = auto() + class ActionTypes(IntEnum): ACCEPT_BID = auto() diff --git a/basicswap/http_server.py b/basicswap/http_server.py index a440f0a..bdb3cf5 100644 --- a/basicswap/http_server.py +++ b/basicswap/http_server.py @@ -213,7 +213,7 @@ class HttpHandler(BaseHTTPRequestHandler): status_code=200, version=__version__, extra_headers=None, - ): + ) -> bytes: swap_client = self.server.swap_client if swap_client.ws_server: args_dict["ws_port"] = swap_client.ws_server.client_port diff --git a/basicswap/interface/bch.py b/basicswap/interface/bch.py index 40fa27f..91f9beb 100644 --- a/basicswap/interface/bch.py +++ b/basicswap/interface/bch.py @@ -160,14 +160,23 @@ class BCHInterface(BTCInterface): amount: int, sub_fee: bool = False, lock_unspents: bool = True, + feerate: int = None, ) -> str: txn = self.rpc( "createrawtransaction", [[], {addr_to: self.format_amount(amount)}] ) + if feerate: + fee_rate = self.format_amount(feerate) + fee_src = "specified" + else: + fee_rate, fee_src = self.get_fee_rate(self._conf_target) + self._log.debug( + f"Fee rate: {fee_rate}, source: {fee_src}, block target: {self._conf_target}" + ) options = { "lockUnspents": lock_unspents, - # 'conf_target': self._conf_target, + "feeRate": fee_rate, } if sub_fee: options["subtractFeeFromOutputs"] = [ diff --git a/basicswap/interface/btc.py b/basicswap/interface/btc.py index 5df6bf5..8cf1ed8 100644 --- a/basicswap/interface/btc.py +++ b/basicswap/interface/btc.py @@ -1873,7 +1873,9 @@ class BTCInterface(FeeValidator, Secp256k1Interface): pubkey = PublicKey(K) return pubkey.verify(sig[:-1], sig_hash, hasher=None) # Pop the hashtype byte - def fundTx(self, tx: bytes, feerate) -> bytes: + def fundTx( + self, tx: bytes, feerate: int, lock_unspents: bool = True, subfee: bool = False + ) -> bytes: if self.useBackend(): return self._fundTxElectrum(tx, feerate) @@ -1881,9 +1883,14 @@ class BTCInterface(FeeValidator, Secp256k1Interface): # TODO: Unlock unspents if bid cancelled # TODO: Manually select only segwit prevouts options = { - "lockUnspents": True, + "lockUnspents": lock_unspents, "feeRate": feerate_str, } + if subfee: + tx_obj = self.loadTx(tx, allow_witness=False) + num_vouts: int = len(tx_obj.vout) + ensure(num_vouts > 0, "Missing tx outputs") + options["subtractFeeFromOutputs"] = list(range(num_vouts)) rv = self.rpc_wallet("fundrawtransaction", [tx.hex(), options]) tx_bytes: bytes = bytes.fromhex(rv["hex"]) return tx_bytes @@ -2705,10 +2712,17 @@ class BTCInterface(FeeValidator, Secp256k1Interface): tx.vout.append(self.txoType()(output_amount, p2wpkh_script_pk)) return tx.serialize() - def encodeSharedAddress(self, Kbv, Kbs): + def encodeSharedAddress(self, Kbv: bytes, Kbs: bytes) -> str: return self.pubkey_to_segwit_address(Kbs) - def publishBLockTx(self, kbv, Kbs, output_amount, feerate, unlock_time: int = 0): + def publishBLockTx( + self, + kbv: bytes, + Kbs: bytes, + output_amount: int, + feerate: int, + unlock_time: int = 0, + ) -> (bytes, int): b_lock_tx = self.createBLockTx(Kbs, output_amount) b_lock_tx = self.fundTx(b_lock_tx, feerate) @@ -2731,8 +2745,8 @@ class BTCInterface(FeeValidator, Secp256k1Interface): def findTxB( self, - kbv, - Kbs, + kbv: bytes, + Kbs: bytes, cb_swap_value: int, cb_block_confirmed: int, restore_height: int, @@ -3461,6 +3475,7 @@ class BTCInterface(FeeValidator, Secp256k1Interface): amount: int, sub_fee: bool = False, lock_unspents: bool = True, + feerate: int = None, ) -> str: if self.useBackend(): return self._createRawFundedTransactionElectrum(addr_to, amount, sub_fee) @@ -3468,10 +3483,18 @@ class BTCInterface(FeeValidator, Secp256k1Interface): txn = self.rpc( "createrawtransaction", [[], {addr_to: self.format_amount(amount)}] ) + if feerate: + fee_rate = self.format_amount(feerate) + fee_src = "specified" + else: + fee_rate, fee_src = self.get_fee_rate(self._conf_target) + self._log.debug( + f"Fee rate: {fee_rate}, source: {fee_src}, block target: {self._conf_target}" + ) options = { "lockUnspents": lock_unspents, - "conf_target": self._conf_target, + "feeRate": fee_rate, } if sub_fee: options["subtractFeeFromOutputs"] = [ @@ -4492,6 +4515,40 @@ class BTCInterface(FeeValidator, Secp256k1Interface): return tx["txid"] + def validatePrefundedTxAmounts(self, tx_data: bytes) -> (bytes, int): + # unspent_utxos = self.listUtxos() + tx_obj = self.loadTx(tx_data, allow_witness=False) + + total_out: int = 0 + total_in: int = 0 + for txo in tx_obj.vout: + total_out += txo.nValue + + dummy_witness_stack = [] + used_utxos = set() + for txi in tx_obj.vin: + txi_txid_hex: str = i2h(txi.prevout.hash) + txi_vout: int = txi.prevout.n + if (txi_txid_hex, txi_vout) in used_utxos: + raise ValueError(f"Duplicate txin {txi_txid_hex} {txi_vout}") + + prev_tx = self.rpc_wallet("gettransaction", [txi_txid_hex]) + prev_tx_obj = self.describeTx(prev_tx["hex"]) + + txo = prev_tx_obj["vout"][txi_vout] + total_in += self.make_int(txo["value"]) + dummy_witness_stack.append(self.getP2WPKHDummyWitness()) + used_utxos.add((txi_txid_hex, txi_vout)) + + fee: int = total_in - total_out + witness_bytes_len_est: int = self.getWitnessStackSerialisedLength( + dummy_witness_stack + ) + vsize = self.getTxVSize(tx_obj, add_witness_bytes=witness_bytes_len_est) + fee_rate = fee * 1000 // vsize + + return bytes.fromhex(txi_txid_hex), fee_rate + def testBTCInterface(): print("TODO: testBTCInterface") diff --git a/basicswap/interface/dcr/dcr.py b/basicswap/interface/dcr/dcr.py index b69a892..38e77f0 100644 --- a/basicswap/interface/dcr/dcr.py +++ b/basicswap/interface/dcr/dcr.py @@ -856,12 +856,17 @@ class DCRInterface(FeeValidator, Secp256k1Interface): amount: int, sub_fee: bool = False, lock_unspents: bool = True, + feerate: int = None, ) -> str: # amount can't be a string, else: Failed to parse request: parameter #2 'amounts' must be type float64 (got string) float_amount = float(self.format_amount(amount)) txn = self.rpc("createrawtransaction", [[], {addr_to: float_amount}]) - fee_rate, fee_src = self.get_fee_rate(self._conf_target) + if feerate: + fee_rate = feerate + fee_src = "specified" + else: + fee_rate, fee_src = self.get_fee_rate(self._conf_target) self._log.debug( f"Fee rate: {fee_rate}, source: {fee_src}, block target: {self._conf_target}" ) @@ -1071,7 +1076,9 @@ class DCRInterface(FeeValidator, Secp256k1Interface): def decodeRawTransaction(self, tx_hex: str): return self.rpc("decoderawtransaction", [tx_hex]) - def fundTx(self, tx: bytes, feerate) -> bytes: + def fundTx( + self, tx: bytes, feerate: int, lock_unspents: bool = True, subfee: bool = False + ) -> bytes: feerate_str = float(self.format_amount(feerate)) # TODO: unlock unspents if bid cancelled options = { diff --git a/basicswap/interface/firo.py b/basicswap/interface/firo.py index 4bd55a0..13081e4 100644 --- a/basicswap/interface/firo.py +++ b/basicswap/interface/firo.py @@ -305,11 +305,16 @@ class FIROInterface(BTCInterface): amount: int, sub_fee: bool = False, lock_unspents: bool = True, + feerate: int = None, ) -> str: txn = self.rpc( "createrawtransaction", [[], {addr_to: self.format_amount(amount)}] ) - fee_rate, fee_src = self.get_fee_rate(self._conf_target) + if feerate: + fee_rate = self.format_amount(feerate) + fee_src = "specified" + else: + fee_rate, fee_src = self.get_fee_rate(self._conf_target) self._log.debug( f"Fee rate: {fee_rate}, source: {fee_src}, block target: {self._conf_target}" ) diff --git a/basicswap/interface/nav.py b/basicswap/interface/nav.py index a7592f2..955fddc 100644 --- a/basicswap/interface/nav.py +++ b/basicswap/interface/nav.py @@ -316,11 +316,16 @@ class NAVInterface(BTCInterface): amount: int, sub_fee: bool = False, lock_unspents: bool = True, + feerate: int = None, ) -> str: txn = self.rpc( "createrawtransaction", [[], {addr_to: self.format_amount(amount)}] ) - fee_rate, fee_src = self.get_fee_rate(self._conf_target) + if feerate: + fee_rate = self.format_amount(feerate) + fee_src = "specified" + else: + fee_rate, fee_src = self.get_fee_rate(self._conf_target) self._log.debug( f"Fee rate: {fee_rate}, source: {fee_src}, block target: {self._conf_target}" ) @@ -756,7 +761,13 @@ class NAVInterface(BTCInterface): return tx.serialize() - def fundTx(self, tx_hex: str, feerate: int, lock_unspents: bool = True): + def fundTx( + self, + tx_hex: str, + feerate: int, + lock_unspents: bool = True, + subfee: bool = False, + ): feerate_str = self.format_amount(feerate) # TODO: unlock unspents if bid cancelled options = { diff --git a/basicswap/interface/part.py b/basicswap/interface/part.py index 3ebc16e..ed492cb 100644 --- a/basicswap/interface/part.py +++ b/basicswap/interface/part.py @@ -1240,6 +1240,7 @@ class PARTInterfaceBlind(PARTInterface): amount: int, sub_fee: bool = False, lock_unspents: bool = True, + feerate: int = None, ) -> str: # Estimate lock tx size / fee @@ -1279,9 +1280,17 @@ class PARTInterfaceBlind(PARTInterface): } } + if feerate: + fee_rate = self.format_amount(feerate) + fee_src = "specified" + else: + fee_rate, fee_src = self.get_fee_rate(self._conf_target) + self._log.debug( + f"Fee rate: {fee_rate}, source: {fee_src}, block target: {self._conf_target}" + ) options = { "lockUnspents": lock_unspents, - "conf_target": self._conf_target, + "feeRate": fee_rate, } if sub_fee: options["subtractFeeFromOutputs"] = [ diff --git a/basicswap/interface/pivx.py b/basicswap/interface/pivx.py index df9162d..8e5cff5 100644 --- a/basicswap/interface/pivx.py +++ b/basicswap/interface/pivx.py @@ -79,11 +79,16 @@ class PIVXInterface(BTCInterface): amount: int, sub_fee: bool = False, lock_unspents: bool = True, + feerate: int = None, ) -> str: txn = self.rpc( "createrawtransaction", [[], {addr_to: self.format_amount(amount)}] ) - fee_rate, fee_src = self.get_fee_rate(self._conf_target) + if feerate: + fee_rate = self.format_amount(feerate) + fee_src = "specified" + else: + fee_rate, fee_src = self.get_fee_rate(self._conf_target) self._log.debug( f"Fee rate: {fee_rate}, source: {fee_src}, block target: {self._conf_target}" ) diff --git a/basicswap/js_server.py b/basicswap/js_server.py index 495dc16..b0d97cf 100644 --- a/basicswap/js_server.py +++ b/basicswap/js_server.py @@ -1945,6 +1945,32 @@ def js_electrum_discover(self, url_split, post_string, is_json) -> bytes: ) +def js_getsubfeebidtx(self, url_split, post_string, is_json) -> bytes: + swap_client = self.server.swap_client + swap_client.checkSystemStatus() + post_data = getFormData(post_string, is_json) + offer_id = bytes.fromhex(get_data_entry(post_data, "offer_id")) + offer = swap_client.getOffer(offer_id) + ensure(offer, "Offer not found.") + ci_from = swap_client.ci(offer.coin_from) + ci_to = swap_client.ci(offer.coin_to) + + amount_to: int = inputAmount(get_data_entry(post_data, "amount_to"), ci_to) + bid_rate: int = ci_to.make_int(get_data_entry(post_data, "bid_rate"), r=1) + + prefunded_data = swap_client.createSubfeeBidTx(offer_id, amount_to, bid_rate) + return bytes( + json.dumps( + { + "amount_from": ci_from.format_amount(prefunded_data["amount_from"]), + "amount_to": ci_to.format_amount(prefunded_data["amount_to"]), + "bid_tx": prefunded_data["bid_tx"].hex(), + } + ), + "UTF-8", + ) + + endpoints = { "coins": js_coins, "walletbalances": js_walletbalances, @@ -1981,6 +2007,7 @@ endpoints = { "messageroutes": js_messageroutes, "electrumdiscover": js_electrum_discover, "modeswitchinfo": js_modeswitchinfo, + "getsubfeebidtx": js_getsubfeebidtx, } diff --git a/basicswap/protocols/__init__.py b/basicswap/protocols/__init__.py index 1bea668..f586439 100644 --- a/basicswap/protocols/__init__.py +++ b/basicswap/protocols/__init__.py @@ -15,9 +15,6 @@ from basicswap.interface.btc import ( class ProtocolInterface: swap_type = None - def getFundedInitiateTxTemplate(self, ci, amount: int, sub_fee: bool) -> bytes: - raise ValueError("base class") - def getMockScript(self) -> bytearray: return bytearray([OpCodes.OP_RETURN, OpCodes.OP_1]) @@ -29,7 +26,7 @@ class ProtocolInterface: else ci.get_p2sh_script_pubkey(script) ) - def getMockAddrTo(self, ci): + def getMockScriptAddr(self, ci): script = self.getMockScript() return ( ci.encodeScriptDest(ci.getScriptDest(script)) @@ -38,5 +35,5 @@ class ProtocolInterface: ) def findMockVout(self, ci, itx_decoded): - mock_addr = self.getMockAddrTo(ci) + mock_addr = self.getMockScriptAddr(ci) return find_vout_for_address_from_txobj(itx_decoded, mock_addr) diff --git a/basicswap/protocols/atomic_swap_1.py b/basicswap/protocols/atomic_swap_1.py index 788ea87..3647778 100644 --- a/basicswap/protocols/atomic_swap_1.py +++ b/basicswap/protocols/atomic_swap_1.py @@ -138,10 +138,12 @@ def redeemITx(self, bid_id: bytes, cursor): class AtomicSwapInterface(ProtocolInterface): swap_type = SwapTypes.SELLER_FIRST - def getFundedInitiateTxTemplate(self, ci, amount: int, sub_fee: bool) -> bytes: - addr_to = self.getMockAddrTo(ci) + def getFundedInitiateTxTemplate( + self, ci, amount: int, sub_fee: bool, feerate: int = None + ) -> bytes: + addr_to = self.getMockScriptAddr(ci) funded_tx = ci.createRawFundedTransaction( - addr_to, amount, sub_fee, lock_unspents=False + addr_to, amount, sub_fee, lock_unspents=False, feerate=feerate ) return bytes.fromhex(funded_tx) diff --git a/basicswap/protocols/xmr_swap_1.py b/basicswap/protocols/xmr_swap_1.py index affbb56..d9b03b0 100644 --- a/basicswap/protocols/xmr_swap_1.py +++ b/basicswap/protocols/xmr_swap_1.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # Copyright (c) 2020-2024 tecnovert -# Copyright (c) 2024-2025 The Basicswap developers +# Copyright (c) 2024-2026 The Basicswap developers # Distributed under the MIT software license, see the accompanying # file LICENSE or http://www.opensource.org/licenses/mit-license.php. @@ -11,6 +11,7 @@ from basicswap.util import ( ensure, ) from basicswap.interface.base import Curves +from basicswap.interface.btc import findOutput from basicswap.chainparams import ( Coins, ) @@ -203,6 +204,9 @@ def setDLEAG(xmr_swap, ci_to, kbsf: bytes) -> None: class XmrSwapInterface(ProtocolInterface): swap_type = SwapTypes.XMR_SWAP + _mock_key: bytes = bytes.fromhex( + "e6b8e7c2ca3a88fe4f28591aa0f91fec340179346559e4ec430c2531aecc19aa" + ) def genScriptLockTxScript(self, ci, Kal: bytes, Kaf: bytes, **kwargs) -> CScript: # fallthrough to ci if genScriptLockTxScript is implemented there @@ -214,20 +218,40 @@ class XmrSwapInterface(ProtocolInterface): return CScript([2, Kal, Kaf, 2, CScriptOp(OP_CHECKMULTISIG)]) - def getFundedInitiateTxTemplate(self, ci, amount: int, sub_fee: bool) -> bytes: - addr_to = self.getMockAddrTo(ci) + def getFundedInitiateTxTemplate( + self, ci, amount: int, sub_fee: bool, feerate: int = None + ) -> bytes: + addr_to = self.getMockScriptAddr(ci) funded_tx = ci.createRawFundedTransaction( - addr_to, amount, sub_fee, lock_unspents=False + addr_to, amount, sub_fee, lock_unspents=False, feerate=feerate ) - return bytes.fromhex(funded_tx) + def getMockITxSwapValue(self, ci, tx_data: bytes) -> int: + script: bytes = self.getMockScript() + script_dest: bytes = ci.getScriptDest(script) + tx_obj = ci.loadTx(tx_data, allow_witness=False) + + lock_vout = findOutput(tx_obj, script_dest) + if lock_vout < 0: + raise ValueError("swap output not found") + + return tx_obj.vout[lock_vout].nValue + + def getMockITxSwapVout(self, ci, tx_obj) -> int: + script: bytes = self.getMockScript() + script_dest: bytes = ci.getScriptDest(script) + lock_vout = findOutput(tx_obj, script_dest) + if lock_vout is None: + raise ValueError("swap output not found") + return lock_vout + def promoteMockTx(self, ci, mock_tx: bytes, script: bytearray) -> bytearray: mock_txo_script = self.getMockScriptScriptPubkey(ci) real_txo_script = ci.getScriptDest(script) found: int = 0 - ctx = ci.loadTx(mock_tx) + ctx = ci.loadTx(mock_tx, allow_witness=False) for txo in ctx.vout: if txo.scriptPubKey == mock_txo_script: txo.scriptPubKey = real_txo_script @@ -239,4 +263,37 @@ class XmrSwapInterface(ProtocolInterface): raise ValueError("Too many mocked outputs found") ctx.nLockTime = 0 - return ctx.serialize() + return ctx.serialize_without_witness() + + def getMockPubkey(self, ci) -> bytes: + return ci.getPubkey(self._mock_key) + + def getMockPTxSwapValue(self, ci, tx_data: bytes) -> int: + mock_pk: bytes = self.getMockPubkey(ci) + script_pk = ci.getPkDest(mock_pk) + tx_obj = ci.loadTx(tx_data, allow_witness=False) + + lock_vout = findOutput(tx_obj, script_pk) + if lock_vout < 0: + raise ValueError("swap output not found") + + return tx_obj.vout[lock_vout].nValue + + def getMockPTxSwapVout(self, ci, tx_obj) -> int: + mock_pk: bytes = self.getMockPubkey(ci) + script_pk = ci.getPkDest(mock_pk) + lock_vout = findOutput(tx_obj, script_pk) + if lock_vout is None: + raise ValueError("swap output not found") + return lock_vout + + def promoteMockPTx(self, ci, tx_data: bytes, kbv: bytes, Kbs: bytes) -> bytes: + mock_pk: bytes = self.getMockPubkey(ci) + script_pk = ci.getPkDest(mock_pk) + tx_obj = ci.loadTx(tx_data) + lock_vout = findOutput(tx_obj, script_pk) + if lock_vout < 0: + raise ValueError("swap output not found") + tx_obj.vout[lock_vout].scriptPubKey = ci.getPkDest(Kbs) + + return tx_obj.serialize_without_witness() diff --git a/basicswap/static/js/modules/event-handlers.js b/basicswap/static/js/modules/event-handlers.js index 35557e8..1bb79bc 100644 --- a/basicswap/static/js/modules/event-handlers.js +++ b/basicswap/static/js/modules/event-handlers.js @@ -183,6 +183,55 @@ } }, + setBidAmount: function(percent, inputId) { + const amountInput = window.DOMCache + ? window.DOMCache.get(inputId) + : document.getElementById(inputId); + + if (!amountInput) { + console.error('EventHandlers: Bid amount input not found:', inputId); + return; + } + + const haveBalance = amountInput.getAttribute('haveamount'); + if (!haveBalance) { + console.error('EventHandlers: Balance not found for bid'); + return; + } + const floatBalance = parseFloat(haveBalance); + if (isNaN(floatBalance)) { + alert('Invalid bid balance'); + return; + } + + const maxAmount = amountInput.getAttribute('max'); + if (!maxAmount) { + console.error('EventHandlers: Max amount not found for bid'); + return; + } + const floatMax = parseFloat(maxAmount); + if (isNaN(floatMax) || floatMax <= 0) { + alert('Invalid bid max amount'); + return; + } + + const coinExp = amountInput.getAttribute('exp'); + if (!coinExp) { + console.error('EventHandlers: Coin exp not found for bid'); + return; + } + let calculatedAmount = maxAmount * percent; + if (floatBalance < calculatedAmount) { + calculatedAmount = floatBalance; + const checkbox = document.getElementById('subfee_bid'); + if (checkbox) { + checkbox.checked = true; + } + } + amountInput.value = calculatedAmount.toFixed(coinExp); + window.updateBidParams('sending'); + }, + hideConfirmModal: function() { const modal = document.getElementById('confirmModal'); if (modal) { @@ -192,7 +241,6 @@ }, lookup_rates: function() { - if (window.lookup_rates && typeof window.lookup_rates === 'function') { window.lookup_rates(); } else { @@ -346,6 +394,16 @@ } }); + document.addEventListener('click', (e) => { + const target = e.target.closest('[data-set-bid-amount]'); + if (target) { + e.preventDefault(); + const percent = parseFloat(target.getAttribute('data-set-bid-amount')); + const inputId = target.getAttribute('data-input-id'); + this.setBidAmount(percent, inputId); + } + }); + document.addEventListener('click', (e) => { const target = e.target.closest('[data-reset-form]'); if (target) { @@ -419,6 +477,7 @@ window.fillDonationAddress = EventHandlers.fillDonationAddress.bind(EventHandlers); window.setAmmAmount = EventHandlers.setAmmAmount.bind(EventHandlers); window.setOfferAmount = EventHandlers.setOfferAmount.bind(EventHandlers); + window.setBidAmount = EventHandlers.setBidAmount.bind(EventHandlers); window.resetForm = EventHandlers.resetForm.bind(EventHandlers); window.hideConfirmModal = EventHandlers.hideConfirmModal.bind(EventHandlers); window.toggleNotificationDropdown = EventHandlers.toggleNotificationDropdown.bind(EventHandlers); diff --git a/basicswap/static/js/pages/offer-page.js b/basicswap/static/js/pages/offer-page.js index f62afaa..c5f998b 100644 --- a/basicswap/static/js/pages/offer-page.js +++ b/basicswap/static/js/pages/offer-page.js @@ -4,11 +4,13 @@ const OfferPage = { xhr_rates: null, xhr_bid_params: null, + xhr_bid_prefund: null, init: function() { this.xhr_rates = new XMLHttpRequest(); this.xhr_bid_params = new XMLHttpRequest(); - + this.xhr_bid_prefund = new XMLHttpRequest(); + this.setupXHRHandlers(); this.setupEventListeners(); this.handleBidsPageAddress(); @@ -36,6 +38,20 @@ this.updateModalValues(); } }; + + this.xhr_bid_prefund.onload = () => { + if (this.xhr_bid_prefund.status == 200) { + const obj = JSON.parse(this.xhr_bid_prefund.response); + const bidAmountInput = document.getElementById('bid_amount'); + if (bidAmountInput) { + bidAmountInput.value = obj['amount_from']; + } + const prefundedBidInput = document.getElementById('prefunded_bid_tx'); + if (prefundedBidInput) { + prefundedBidInput.value = obj['bid_tx']; + } + } + }; }, setupEventListeners: function() { @@ -112,7 +128,7 @@ const bidRateInput = document.getElementById('bid_rate'); const validMinsInput = document.querySelector('input[name="validmins"]'); const amtVar = document.getElementById('amt_var')?.value === 'True'; - + if (bidAmountSendInput) { bidAmountSendInput.value = amtVar ? '' : bidAmountSendInput.getAttribute('max'); } @@ -130,7 +146,7 @@ this.updateBidParams('rate'); } this.updateModalValues(); - + const errorMessages = document.querySelectorAll('.error-message'); errorMessages.forEach(msg => msg.remove()); @@ -156,6 +172,7 @@ const bidAmountSendInput = document.getElementById('bid_amount_send'); const bidRateInput = document.getElementById('bid_rate'); const offerRateInput = document.getElementById('offer_rate'); + const bidSubfee = document.getElementById('subfee_bid'); if (!coin_from || !coin_to || !amt_var || !rate_var) return; @@ -171,7 +188,7 @@ const receiveAmount = (sendAmount / rate).toFixed(coin_from_exp); bidAmountInput.value = receiveAmount; } - } else if (value_changed === 'sending') { + } else if (value_changed === 'sending' || value_changed === 'subfee') { if (bidAmountSendInput && bidAmountInput) { const sendAmount = parseFloat(bidAmountSendInput.value) || 0; const receiveAmount = (sendAmount / rate).toFixed(coin_from_exp); @@ -187,8 +204,30 @@ this.validateAmountsAfterChange(); + if (bidSubfee && bidSubfee.checked) { + bidAmountInput.readOnly = true; + + const offer_id = document.getElementById('offer_id')?.value || ''; + if (!offer_id) { + console.log("offer_id not found!"); + return; + } + this.xhr_bid_prefund.open('POST', '/json/getsubfeebidtx'); + this.xhr_bid_prefund.setRequestHeader('Content-type', 'application/json;charset=UTF-8'); + const data = { offer_id: offer_id, amount_to: bidAmountSendInput.value , bid_rate: rate}; + this.xhr_bid_prefund.overrideMimeType("application/json"); + this.xhr_bid_prefund.send(JSON.stringify(data)); + return; + } + bidAmountInput.readOnly = false; + const prefundedBidInput = document.getElementById('prefunded_bid_tx'); + if (prefundedBidInput) { + prefundedBidInput.value = ""; + } + this.xhr_bid_params.open('POST', '/json/rate'); this.xhr_bid_params.setRequestHeader('Content-type', 'application/x-www-form-urlencoded'); + this.xhr_bid_params.overrideMimeType("application/json"); this.xhr_bid_params.send(`coin_from=${coin_from}&coin_to=${coin_to}&rate=${rate}&amt_from=${bidAmountInput?.value || '0'}`); this.updateModalValues(); @@ -253,6 +292,11 @@ this.showErrorModal('Validation Error', 'Please enter valid amounts for both sending and receiving.'); return false; } + let subfee = false; + const checkbox = document.getElementById('subfee_bid'); + if (checkbox) { + subfee = checkbox.checked; + } const coinFrom = document.getElementById('coin_from_name')?.value || ''; const coinTo = document.getElementById('coin_to_name')?.value || ''; @@ -273,7 +317,12 @@ if (modalAmtReceive) modalAmtReceive.textContent = receiveAmount.toFixed(8); if (modalReceiveCurrency) modalReceiveCurrency.textContent = ` ${tlaFrom}`; if (modalAmtSend) modalAmtSend.textContent = sendAmount.toFixed(8); - if (modalSendCurrency) modalSendCurrency.textContent = ` ${tlaTo}`; + if (modalSendCurrency) { + modalSendCurrency.textContent = ` ${tlaTo}`; + if (subfee) { + modalSendCurrency.textContent += ` (incl fee)`; + } + } if (modalAddrFrom) modalAddrFrom.textContent = addrFrom || 'Default'; if (modalValidMins) modalValidMins.textContent = validMins; @@ -293,7 +342,7 @@ }, updateModalValues: function() { - + }, handleBidsPageAddress: function() { diff --git a/basicswap/templates/offer.html b/basicswap/templates/offer.html index c250945..5e0d793 100644 --- a/basicswap/templates/offer.html +++ b/basicswap/templates/offer.html @@ -419,11 +419,22 @@ name="bid_amount_send" value="" max="{{ data.amt_to }}" + haveamount="{{ data.coin_to_balance }}" + exp="{{ data.coin_to_exp }}" onchange="validateMaxAmount(this, parseFloat('{{ data.amt_to }}')); updateBidParams('sending');">
max {{ data.amt_to }} ({{ data.tla_to }})
+
+ + {% if data.bid_can_subfee == true %} + + {% endif %} +
@@ -721,6 +732,8 @@ + +

diff --git a/basicswap/ui/page_offers.py b/basicswap/ui/page_offers.py index 48f33d9..f510386 100644 --- a/basicswap/ui/page_offers.py +++ b/basicswap/ui/page_offers.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright (c) 2022-2024 tecnovert +# Copyright (c) 2022-2026 tecnovert # Copyright (c) 2024 The Basicswap developers # Distributed under the MIT software license, see the accompanying # file LICENSE or http://www.opensource.org/licenses/mit-license.php. @@ -8,6 +8,7 @@ import traceback import time +from typing import List from urllib import parse from .util import ( getCoinType, @@ -583,7 +584,7 @@ def page_newoffer(self, url_split, post_string): ) -def page_offer(self, url_split, post_string): +def page_offer(self, url_split: List[str], post_string: str) -> bytes: ensure(len(url_split) > 2, "Offer ID not specified") offer_id = decode_offer_id(url_split[2]) server = self.server @@ -674,6 +675,11 @@ def page_offer(self, url_split, post_string): amount_from = offer.amount_from debugind = int(get_data_entry_or(form_data, "debugind", -1)) + if have_data_entry(form_data, "subfee_bid"): + extra_options["prefunded_tx"] = bytes.fromhex( + get_data_entry(form_data, "prefunded_bid_tx") + ) + sent_bid_id = swap_client.postBid( offer_id, amount_from, @@ -823,6 +829,33 @@ def page_offer(self, url_split, post_string): ) data["amt_swapped"] = ci_from.format_amount(amt_swapped) + if show_bid_form: + coin_to_id = int(ci_to.coin_type()) + wallet_coin_to_id = coin_to_id + if coin_to_id in (Coins.PART_ANON, Coins.PART_BLIND): + wallet_coin_to_id = Coins.PART + + swap_client.updateWalletsInfo(only_coin=wallet_coin_to_id) + coin_to_wallet = swap_client.getCachedWalletsInfo( + {"coin_id": wallet_coin_to_id} + )[wallet_coin_to_id] + if coin_to_id == Coins.PART_ANON: + balance_key = "anon_balance" + elif coin_to_id == Coins.PART_BLIND: + balance_key = "blind_balance" + else: + balance_key = "balance" + data["coin_to_balance"] = coin_to_wallet[balance_key] + + bid_can_subfee: bool = True + if offer.swap_type != SwapTypes.XMR_SWAP: + bid_can_subfee = False + if coin_to_id in (Coins.XMR, Coins.WOW): + bid_can_subfee = False + if offer.amount_negotiable is False: + bid_can_subfee = False + data["bid_can_subfee"] = bid_can_subfee + template = server.env.get_template("offer.html") return self.render_template( template, @@ -841,7 +874,9 @@ def page_offer(self, url_split, post_string): ) -def format_timestamp(timestamp, with_ago=True, is_expired=False): +def format_timestamp( + timestamp: int, with_ago: bool = True, is_expired: bool = False +) -> str: current_time = int(time.time()) if is_expired: diff --git a/doc/release-notes.md b/doc/release-notes.md index dfd8417..94444d5 100644 --- a/doc/release-notes.md +++ b/doc/release-notes.md @@ -13,11 +13,14 @@ - New setting "startup_delay" - Adjusts the time waited for coin daemons to start between "startup_tries". - Valid as a base setting and can be overridden per coin with chainclients settings. +- Add subfee bids. + - Enables a user to create a bid specifying the amount before the lock tx fee. + - Currently only works when the coin to is not XMR like. - UI: - offer page: - Fixed feerate from other chain displayed for reversed swaps - Added warning text for fee above 1.2 x local estimate. - + - Added subfee bid option. 0.14.5 diff --git a/tests/basicswap/extended/test_pivx.py b/tests/basicswap/extended/test_pivx.py index 49381db..e1aec6c 100644 --- a/tests/basicswap/extended/test_pivx.py +++ b/tests/basicswap/extended/test_pivx.py @@ -849,7 +849,7 @@ class Test(unittest.TestCase): swap_value = ci_from.make_int(swap_value) assert swap_value > ci_from.make_int(9) - addr_to = pi.getMockAddrTo(ci_from) + addr_to = pi.getMockScriptAddr(ci_from) funded_tx = ci_from.createRawFundedTransaction( addr_to, swap_value, True, lock_unspents=True ) diff --git a/tests/basicswap/test_btc_xmr.py b/tests/basicswap/test_btc_xmr.py index 7d5c863..08a9603 100644 --- a/tests/basicswap/test_btc_xmr.py +++ b/tests/basicswap/test_btc_xmr.py @@ -2161,8 +2161,8 @@ class BasicSwapTest(TestFunctions): self.do_test_05_self_bid(Coins.XMR, self.test_coin_from) def test_06_preselect_inputs(self): - tla_from = self.test_coin_from.name - logging.info("---------- Test {} Preselected inputs".format(tla_from)) + tla_from: str = self.test_coin_from.name + logging.info(f"---------- Test {tla_from} Preselected inputs") swap_clients = self.swap_clients self.prepare_balance(self.test_coin_from, 100.0, 1802, 1800) @@ -2262,12 +2262,168 @@ class BasicSwapTest(TestFunctions): assert txin["txid"] == txin_after["txid"] assert txin["vout"] == txin_after["vout"] + def test_06_b_preselect_bid_inputs(self): + coin_from, coin_to = (Coins.PART, self.test_coin_from) + logging.info( + f"---------- Test {coin_from.name} to {coin_to.name} Preselected bid inputs" + ) + + id_offerer, id_bidder = (1, 0) + swap_clients = self.swap_clients + ci_from = swap_clients[id_offerer].ci(coin_from) + ci_to = swap_clients[id_bidder].ci(coin_to) + + amt_swap: int = ci_from.make_int(random.uniform(0.1, 2.0), r=1) + min_swap: int = ci_from.make_int(0.0001) + rate_swap: int = ci_to.make_int(random.uniform(0.2, 20.0), r=1) + + extra_options = { + "amount_negotiable": True, + "automation_id": 1, + } + offer_id = swap_clients[id_offerer].postOffer( + coin_from, + coin_to, + amt_swap, + rate_swap, + min_swap, + SwapTypes.XMR_SWAP, + extra_options=extra_options, + ) + + wait_for_offer(test_delay_event, swap_clients[id_bidder], offer_id) + offer = swap_clients[id_bidder].getOffer(offer_id) + + amount_to = (offer.amount_from * offer.rate) // ci_from.COIN() + prefunded_tx_data = swap_clients[id_bidder].createSubfeeBidTx( + offer_id, amount_to, offer.rate + ) + ptx_decoded = ci_to.describeTx(prefunded_tx_data["bid_tx"].hex()) + ci_to.rpc_wallet("lockunspent", [False, ptx_decoded["vin"]]) + + bid_tx = prefunded_tx_data["bid_tx"] + amount_from = prefunded_tx_data["amount_from"] + + extra_options = {"prefunded_tx": bid_tx} + bid_id = swap_clients[id_bidder].postBid( + offer_id, amount_from, extra_options=extra_options + ) + + wait_for_bid( + test_delay_event, + swap_clients[id_offerer], + bid_id, + BidStates.SWAP_COMPLETED, + wait_for=120, + ) + wait_for_bid( + test_delay_event, + swap_clients[id_bidder], + bid_id, + BidStates.SWAP_COMPLETED, + sent=True, + wait_for=120, + ) + + # Verify expected inputs were used + bid, _, _, _, _ = swap_clients[id_bidder].getXmrBidAndOffer(bid_id) + assert bid.xmr_b_lock_tx + wtx = ci_to.rpc_wallet( + "gettransaction", + [ + bid.xmr_b_lock_tx.txid.hex(), + ], + ) + ptx_after = ci_to.describeTx(wtx["hex"]) + assert len(ptx_after["vin"]) == len(ptx_decoded["vin"]) + for i, txin in enumerate(ptx_decoded["vin"]): + txin_after = ptx_after["vin"][i] + assert txin["txid"] == txin_after["txid"] + assert txin["vout"] == txin_after["vout"] + + def test_06_c_preselect_reverse_bid_inputs(self): + coin_from, coin_to = (Coins.XMR, self.test_coin_from) + logging.info( + f"---------- Test {coin_from.name} to {coin_to.name} Preselected reverse bid inputs" + ) + + id_offerer, id_bidder = (1, 0) + swap_clients = self.swap_clients + ci_from = swap_clients[id_offerer].ci(coin_from) + ci_to = swap_clients[id_bidder].ci(coin_to) + + amt_swap: int = ci_from.make_int(random.uniform(0.1, 2.0), r=1) + min_swap: int = ci_from.make_int(0.0001) + rate_swap: int = ci_to.make_int(random.uniform(0.2, 20.0), r=1) + + extra_options = { + "amount_negotiable": True, + "automation_id": 1, + } + offer_id = swap_clients[id_offerer].postOffer( + coin_from, + coin_to, + amt_swap, + rate_swap, + min_swap, + SwapTypes.XMR_SWAP, + extra_options=extra_options, + ) + + wait_for_offer(test_delay_event, swap_clients[id_bidder], offer_id) + offer = swap_clients[id_bidder].getOffer(offer_id) + + amount_to = (offer.amount_from * offer.rate) // ci_from.COIN() + prefunded_tx_data = swap_clients[id_bidder].createSubfeeBidTx( + offer_id, amount_to, offer.rate + ) + ptx_decoded = ci_to.describeTx(prefunded_tx_data["bid_tx"].hex()) + ci_to.rpc_wallet("lockunspent", [False, ptx_decoded["vin"]]) + + bid_tx = prefunded_tx_data["bid_tx"] + amount_from = prefunded_tx_data["amount_from"] + + extra_options = {"prefunded_tx": bid_tx} + bid_id = swap_clients[id_bidder].postBid( + offer_id, amount_from, extra_options=extra_options + ) + + wait_for_bid( + test_delay_event, + swap_clients[id_offerer], + bid_id, + BidStates.SWAP_COMPLETED, + wait_for=180, + ) + wait_for_bid( + test_delay_event, + swap_clients[id_bidder], + bid_id, + BidStates.SWAP_COMPLETED, + sent=True, + wait_for=120, + ) + + # Verify expected inputs were used + bid, _, _, _, _ = swap_clients[id_bidder].getXmrBidAndOffer(bid_id) + assert bid.xmr_a_lock_tx + wtx = ci_to.rpc_wallet( + "gettransaction", + [ + bid.xmr_a_lock_tx.txid.hex(), + ], + ) + ptx_after = ci_to.describeTx(wtx["hex"]) + assert len(ptx_after["vin"]) == len(ptx_decoded["vin"]) + for i, txin in enumerate(ptx_decoded["vin"]): + txin_after = ptx_after["vin"][i] + assert txin["txid"] == txin_after["txid"] + assert txin["vout"] == txin_after["vout"] + def test_07_expire_stuck_accepted(self): coin_from, coin_to = (self.test_coin_from, Coins.XMR) logging.info( - "---------- Test {} to {} expires bid stuck on accepted".format( - coin_from.name, coin_to.name - ) + f"---------- Test {coin_from.name} to {coin_to.name} expires bid stuck on accepted" ) swap_clients = self.swap_clients @@ -2434,7 +2590,6 @@ class BasicSwapTest(TestFunctions): ci_from._high_feerate = ( 80 # ci_from_settings["high_feerate"] = ci_from.format_amount(80) ) - logging.info(f"[rm] ci_from.get_fee_rate() {ci_from.get_fee_rate()}") try: swap_clients[0].postXmrBid(offer_id, amt_swap) except Exception as e: diff --git a/tests/basicswap/test_other.py b/tests/basicswap/test_other.py index 20c85c2..2782b1c 100644 --- a/tests/basicswap/test_other.py +++ b/tests/basicswap/test_other.py @@ -156,7 +156,7 @@ class Test(unittest.TestCase): assert str(e) == "Mantissa too long" validate_amount("0.12345678") - # floor + # Floor assert make_int("0.123456789", r=-1) == 12345678 # Round up assert make_int("0.123456789", r=1) == 12345679