From c90fa6f2c674cb2486c1f98a2e333863edeb16fc Mon Sep 17 00:00:00 2001 From: tecnovert Date: Mon, 5 Dec 2022 17:04:23 +0200 Subject: [PATCH] system: Allow preselecting inputs for atomic swaps. --- basicswap/__init__.py | 2 +- basicswap/basicswap.py | 76 ++++++++++++++----- basicswap/basicswap_util.py | 4 + basicswap/db.py | 15 +++- basicswap/db_upgrades.py | 13 ++++ basicswap/interface/btc.py | 64 ++++++++++------ .../contrib/pivx_test_framework/messages.py | 7 ++ basicswap/interface/firo.py | 22 +++--- basicswap/interface/pivx.py | 33 +++++--- basicswap/protocols/__init__.py | 12 +++ basicswap/protocols/atomic_swap_1.py | 45 +++++++++++ basicswap/protocols/xmr_swap_1.py | 8 +- basicswap/script.py | 3 +- doc/notes.md | 20 ++++- tests/basicswap/common.py | 18 ++++- tests/basicswap/test_run.py | 64 ++++++++++++++++ 16 files changed, 334 insertions(+), 72 deletions(-) diff --git a/basicswap/__init__.py b/basicswap/__init__.py index 6cec4c2..a77d61b 100644 --- a/basicswap/__init__.py +++ b/basicswap/__init__.py @@ -1,3 +1,3 @@ name = "basicswap" -__version__ = "0.11.51" +__version__ = "0.11.52" diff --git a/basicswap/basicswap.py b/basicswap/basicswap.py index d9dca0d..3289c7b 100644 --- a/basicswap/basicswap.py +++ b/basicswap/basicswap.py @@ -26,6 +26,8 @@ import sqlalchemy as sa import collections import concurrent.futures +from typing import Optional + from sqlalchemy.orm import sessionmaker, scoped_session from sqlalchemy.orm.session import close_all_sessions @@ -92,6 +94,7 @@ from .db import ( Offer, Bid, SwapTx, + PrefundedTx, PooledAddress, SentOffer, SmsgAddress, @@ -116,6 +119,7 @@ from .explorers import ( import basicswap.config as cfg import basicswap.network as bsn import basicswap.protocols.atomic_swap_1 as atomic_swap_1 +import basicswap.protocols.xmr_swap_1 as xmr_swap_1 from .basicswap_util import ( KeyTypes, TxLockTypes, @@ -140,9 +144,6 @@ from .basicswap_util import ( isActiveBidState, NotificationTypes as NT, ) -from .protocols.xmr_swap_1 import ( - addLockRefundSigs, - recoverNoScriptTxnWithKey) non_script_type_coins = (Coins.XMR, Coins.PART_ANON) @@ -218,6 +219,10 @@ class WatchedTransaction(): class BasicSwap(BaseApp): ws_server = None + protocolInterfaces = { + SwapTypes.SELLER_FIRST: atomic_swap_1.AtomicSwapInterface(), + SwapTypes.XMR_SWAP: xmr_swap_1.XmrSwapInterface(), + } def __init__(self, fp, data_dir, settings, chain, log_name='BasicSwap'): super().__init__(fp, data_dir, settings, chain, log_name) @@ -548,6 +553,11 @@ class BasicSwap(BaseApp): return self.coin_clients[use_coinid][interface_ind] + def pi(self, protocol_ind): + if protocol_ind not in self.protocolInterfaces: + raise ValueError('Unknown protocol_ind {}'.format(int(protocol_ind))) + return self.protocolInterfaces[protocol_ind] + def createInterface(self, coin): if coin == Coins.PART: return PARTInterface(self.coin_clients[coin], self.chain, self) @@ -651,10 +661,8 @@ class BasicSwap(BaseApp): self.log.info('%s Core version %d', ci.coin_name(), core_version) self.coin_clients[c]['core_version'] = core_version - if c == Coins.XMR: - t = threading.Thread(target=threadPollXMRChainState, args=(self, c)) - else: - t = threading.Thread(target=threadPollChainState, args=(self, c)) + thread_func = threadPollXMRChainState if c == Coins.XMR else threadPollChainState + t = threading.Thread(target=thread_func, args=(self, c)) self.threads.append(t) t.start() @@ -851,7 +859,7 @@ class BasicSwap(BaseApp): finally: self.closeSession(session) - def updateIdentityBidState(self, session, address, bid): + def updateIdentityBidState(self, session, address: str, bid) -> None: identity_stats = session.query(KnownIdentity).filter_by(address=address).first() if not identity_stats: identity_stats = KnownIdentity(address=address, created_at=int(time.time())) @@ -870,7 +878,7 @@ class BasicSwap(BaseApp): identity_stats.updated_at = int(time.time()) session.add(identity_stats) - def setIntKVInSession(self, str_key, int_val, session): + def setIntKVInSession(self, str_key: str, int_val: int, session) -> None: kv = session.query(DBKVInt).filter_by(key=str_key).first() if not kv: kv = DBKVInt(key=str_key, value=int_val) @@ -878,7 +886,7 @@ class BasicSwap(BaseApp): kv.value = int_val session.add(kv) - def setIntKV(self, str_key, int_val): + def setIntKV(self, str_key: str, int_val: int) -> None: self.mxDB.acquire() try: session = scoped_session(self.session_factory) @@ -889,7 +897,7 @@ class BasicSwap(BaseApp): session.remove() self.mxDB.release() - def setStringKV(self, str_key, str_val, session=None): + def setStringKV(self, str_key: str, str_val: str, session=None) -> None: try: use_session = self.openSession(session) kv = use_session.query(DBKVString).filter_by(key=str_key).first() @@ -902,7 +910,7 @@ class BasicSwap(BaseApp): if session is None: self.closeSession(use_session) - def getStringKV(self, str_key): + def getStringKV(self, str_key: str) -> Optional[str]: self.mxDB.acquire() try: session = scoped_session(self.session_factory) @@ -915,7 +923,7 @@ class BasicSwap(BaseApp): session.remove() self.mxDB.release() - def clearStringKV(self, str_key, str_val): + def clearStringKV(self, str_key: str, str_val: str) -> None: with self.mxDB: try: session = scoped_session(self.session_factory) @@ -925,6 +933,19 @@ class BasicSwap(BaseApp): session.close() session.remove() + def getPreFundedTx(self, linked_type: int, linked_id: bytes, tx_type: int, session=None) -> Optional[bytes]: + try: + use_session = self.openSession(session) + tx = use_session.query(PrefundedTx).filter_by(linked_type=linked_type, linked_id=linked_id, tx_type=tx_type, used_by=None).first() + if not tx: + return None + tx.used_by = linked_id + use_session.add(tx) + return tx.tx_data + finally: + if session is None: + self.closeSession(use_session) + def activateBid(self, session, bid): if bid.bid_id in self.swaps_in_progress: self.log.debug('Bid %s is already in progress', bid.bid_id.hex()) @@ -1366,6 +1387,16 @@ class BasicSwap(BaseApp): repeat_count=0) session.add(auto_link) + if 'prefunded_itx' in extra_options: + prefunded_tx = PrefundedTx( + active_ind=1, + created_at=offer_created_at, + linked_type=Concepts.OFFER, + linked_id=offer_id, + tx_type=TxTypes.ITX_PRE_FUNDED, + tx_data=extra_options['prefunded_itx']) + session.add(prefunded_tx) + session.add(offer) session.add(SentOffer(offer_id=offer_id)) session.commit() @@ -2147,7 +2178,8 @@ class BasicSwap(BaseApp): bid.pkhash_seller = pkhash_refund - txn = self.createInitiateTxn(coin_from, bid_id, bid, script) + prefunded_tx = self.getPreFundedTx(Concepts.OFFER, offer.offer_id, TxTypes.ITX_PRE_FUNDED) + txn = self.createInitiateTxn(coin_from, bid_id, bid, script, prefunded_tx) # Store the signed refund txn in case wallet is locked when refund is possible refund_txn = self.createRefundTxn(coin_from, txn, offer, bid, script) @@ -2532,14 +2564,14 @@ class BasicSwap(BaseApp): session.remove() self.mxDB.release() - def setBidError(self, bid_id, bid, error_str, save_bid=True, xmr_swap=None): + def setBidError(self, bid_id, bid, error_str, save_bid=True, xmr_swap=None) -> None: self.log.error('Bid %s - Error: %s', bid_id.hex(), error_str) bid.setState(BidStates.BID_ERROR) bid.state_note = 'error msg: ' + error_str if save_bid: self.saveBid(bid_id, bid, xmr_swap=xmr_swap) - def createInitiateTxn(self, coin_type, bid_id, bid, initiate_script): + def createInitiateTxn(self, coin_type, bid_id, bid, initiate_script, prefunded_tx=None) -> Optional[str]: if self.coin_clients[coin_type]['connection_type'] != 'rpc': return None ci = self.ci(coin_type) @@ -2550,7 +2582,11 @@ class BasicSwap(BaseApp): addr_to = ci.encode_p2sh(initiate_script) self.log.debug('Create initiate txn for coin %s to %s for bid %s', str(coin_type), addr_to, bid_id.hex()) - txn_signed = ci.createRawSignedTransaction(addr_to, bid.amount) + if prefunded_tx: + pi = self.pi(SwapTypes.SELLER_FIRST) + txn_signed = pi.promoteMockTx(ci, prefunded_tx, initiate_script).hex() + else: + txn_signed = ci.createRawSignedTransaction(addr_to, bid.amount) return txn_signed def deriveParticipateScript(self, bid_id, bid, offer): @@ -4560,7 +4596,7 @@ class BasicSwap(BaseApp): prevout_amount = ci_from.getLockTxSwapOutputValue(bid, xmr_swap) xmr_swap.af_lock_refund_tx_sig = ci_from.signTx(kaf, xmr_swap.a_lock_refund_tx, 0, xmr_swap.a_lock_tx_script, prevout_amount) - addLockRefundSigs(self, xmr_swap, ci_from) + xmr_swap_1.addLockRefundSigs(self, xmr_swap, ci_from) msg_buf = XmrBidLockTxSigsMessage( bid_msg_id=bid_id, @@ -4988,7 +5024,7 @@ class BasicSwap(BaseApp): v = ci_from.verifyTxSig(xmr_swap.a_lock_refund_spend_tx, xmr_swap.af_lock_refund_spend_tx_sig, xmr_swap.pkaf, 0, xmr_swap.a_lock_refund_tx_script, prevout_amount) ensure(v, 'Invalid signature for lock refund spend txn') - addLockRefundSigs(self, xmr_swap, ci_from) + xmr_swap_1.addLockRefundSigs(self, xmr_swap, ci_from) delay = random.randrange(self.min_delay_event, self.max_delay_event) self.log.info('Sending coin A lock tx for xmr bid %s in %d seconds', bid_id.hex(), delay) @@ -5268,7 +5304,7 @@ class BasicSwap(BaseApp): has_changed = True if data['kbs_other'] is not None: - return recoverNoScriptTxnWithKey(self, bid_id, data['kbs_other']) + return xmr_swap_1.recoverNoScriptTxnWithKey(self, bid_id, data['kbs_other']) if has_changed: session = scoped_session(self.session_factory) diff --git a/basicswap/basicswap_util.py b/basicswap/basicswap_util.py index bc83d61..85f1fed 100644 --- a/basicswap/basicswap_util.py +++ b/basicswap/basicswap_util.py @@ -123,6 +123,8 @@ class TxTypes(IntEnum): XMR_SWAP_A_LOCK_REFUND_SWIPE = auto() XMR_SWAP_B_LOCK = auto() + ITX_PRE_FUNDED = auto() + class ActionTypes(IntEnum): ACCEPT_BID = auto() @@ -289,6 +291,8 @@ def strTxType(tx_type): return 'Chain A Lock Refund Swipe Tx' if tx_type == TxTypes.XMR_SWAP_B_LOCK: return 'Chain B Lock Tx' + if tx_type == TxTypes.ITX_PRE_FUNDED: + return 'Funded mock initiate tx' return 'Unknown' diff --git a/basicswap/db.py b/basicswap/db.py index 6cd880f..4bb8198 100644 --- a/basicswap/db.py +++ b/basicswap/db.py @@ -12,7 +12,7 @@ from enum import IntEnum, auto from sqlalchemy.ext.declarative import declarative_base -CURRENT_DB_VERSION = 16 +CURRENT_DB_VERSION = 17 CURRENT_DB_DATA_VERSION = 2 Base = declarative_base() @@ -221,6 +221,19 @@ class SwapTx(Base): self.states = (self.states if self.states is not None else bytes()) + struct.pack(' str: return self.rpc_callback('getwalletinfo')['hdseedid'] - def checkExpectedSeed(self, expect_seedid): + def checkExpectedSeed(self, expect_seedid) -> bool: self._expect_seedid_hex = expect_seedid return expect_seedid == self.getWalletSeedID() - def getNewAddress(self, use_segwit, label='swap_receive'): + def getNewAddress(self, use_segwit: bool, label: str = 'swap_receive') -> str: args = [label] if use_segwit: args.append('bech32') return self.rpc_callback('getnewaddress', args) - def isAddressMine(self, address): + def isAddressMine(self, address: str) -> bool: addr_info = self.rpc_callback('getaddressinfo', [address]) return addr_info['ismine'] - def checkAddressMine(self, address): + def checkAddressMine(self, address: str) -> None: addr_info = self.rpc_callback('getaddressinfo', [address]) ensure(addr_info['ismine'], 'ismine is false') ensure(addr_info['hdseedid'] == self._expect_seedid_hex, 'unexpected seedid') @@ -914,7 +916,7 @@ class BTCInterface(CoinInterface): def encodeTx(self, tx): return tx.serialize() - def loadTx(self, tx_bytes): + def loadTx(self, tx_bytes) -> CTransaction: # Load tx from bytes to internal representation tx = CTransaction() tx.deserialize(BytesIO(tx_bytes)) @@ -963,23 +965,23 @@ class BTCInterface(CoinInterface): # TODO: filter errors return None - def setTxSignature(self, tx_bytes, stack): + def setTxSignature(self, tx_bytes, stack) -> bytes: tx = self.loadTx(tx_bytes) tx.wit.vtxinwit.clear() tx.wit.vtxinwit.append(CTxInWitness()) tx.wit.vtxinwit[0].scriptWitness.stack = stack return tx.serialize() - def stripTxSignature(self, tx_bytes): + def stripTxSignature(self, tx_bytes) -> bytes: tx = self.loadTx(tx_bytes) tx.wit.vtxinwit.clear() return tx.serialize() - def extractLeaderSig(self, tx_bytes): + def extractLeaderSig(self, tx_bytes) -> bytes: tx = self.loadTx(tx_bytes) return tx.wit.vtxinwit[0].scriptWitness.stack[1] - def extractFollowerSig(self, tx_bytes): + def extractFollowerSig(self, tx_bytes) -> bytes: tx = self.loadTx(tx_bytes) return tx.wit.vtxinwit[0].scriptWitness.stack[2] @@ -1142,7 +1144,7 @@ class BTCInterface(CoinInterface): rv = pubkey.verify_compact(sig, message_hash, hasher=None) assert (rv is True) - def verifyMessage(self, address, message, signature, message_magic=None) -> bool: + def verifyMessage(self, address: str, message: str, signature: str, message_magic: str = None) -> bool: if message_magic is None: message_magic = self.chainparams()['message_magic'] @@ -1209,13 +1211,13 @@ class BTCInterface(CoinInterface): length += 1 # flags return length - def describeTx(self, tx_hex): + def describeTx(self, tx_hex: str): return self.rpc_callback('decoderawtransaction', [tx_hex]) def getSpendableBalance(self): return self.make_int(self.rpc_callback('getbalances')['mine']['trusted']) - def createUTXO(self, value_sats): + def createUTXO(self, value_sats: int): # Create a new address and send value_sats to it spendable_balance = self.getSpendableBalance() @@ -1225,18 +1227,22 @@ class BTCInterface(CoinInterface): address = self.getNewAddress(self._use_segwit, 'create_utxo') return self.withdrawCoin(self.format_amount(value_sats), address, False), address - def createRawSignedTransaction(self, addr_to, amount): + def createRawFundedTransaction(self, addr_to: str, amount: int, sub_fee: bool = False, lock_unspents: bool = True) -> str: txn = self.rpc_callback('createrawtransaction', [[], {addr_to: self.format_amount(amount)}]) options = { - 'lockUnspents': True, + 'lockUnspents': lock_unspents, 'conf_target': self._conf_target, } - txn_funded = self.rpc_callback('fundrawtransaction', [txn, options])['hex'] - txn_signed = self.rpc_callback('signrawtransactionwithwallet', [txn_funded])['hex'] - return txn_signed + if sub_fee: + options['subtractFeeFromOutputs'] = [0,] + return self.rpc_callback('fundrawtransaction', [txn, options])['hex'] - def getBlockWithTxns(self, block_hash): + def createRawSignedTransaction(self, addr_to, amount) -> str: + txn_funded = self.createRawFundedTransaction(addr_to, amount) + return self.rpc_callback('signrawtransactionwithwallet', [txn_funded])['hex'] + + def getBlockWithTxns(self, block_hash: str): return self.rpc_callback('getblock', [block_hash, 2]) def getUnspentsByAddr(self): @@ -1248,7 +1254,7 @@ class BTCInterface(CoinInterface): unspent_addr[u['address']] = unspent_addr.get(u['address'], 0) + self.make_int(u['amount'], r=1) return unspent_addr - def getUTXOBalance(self, address): + def getUTXOBalance(self, address: str): num_blocks = self.rpc_callback('getblockcount') sum_unspent = 0 @@ -1292,11 +1298,11 @@ class BTCInterface(CoinInterface): return self.getUTXOBalance(address) - def isWalletEncrypted(self): + def isWalletEncrypted(self) -> bool: wallet_info = self.rpc_callback('getwalletinfo') return 'unlocked_until' in wallet_info - def isWalletLocked(self): + def isWalletLocked(self) -> bool: wallet_info = self.rpc_callback('getwalletinfo') if 'unlocked_until' in wallet_info and wallet_info['unlocked_until'] <= 0: return True @@ -1308,7 +1314,7 @@ class BTCInterface(CoinInterface): locked = encrypted and wallet_info['unlocked_until'] <= 0 return encrypted, locked - def changeWalletPassword(self, old_password, new_password): + def changeWalletPassword(self, old_password: str, new_password: str): self._log.info('changeWalletPassword - {}'.format(self.ticker())) if old_password == '': if self.isWalletEncrypted(): @@ -1316,7 +1322,7 @@ class BTCInterface(CoinInterface): return self.rpc_callback('encryptwallet', [new_password]) self.rpc_callback('walletpassphrasechange', [old_password, new_password]) - def unlockWallet(self, password): + def unlockWallet(self, password: str): if password == '': return self._log.info('unlockWallet - {}'.format(self.ticker())) @@ -1327,6 +1333,14 @@ class BTCInterface(CoinInterface): self._log.info('lockWallet - {}'.format(self.ticker())) self.rpc_callback('walletlock') + def get_p2sh_script_pubkey(self, script: bytearray) -> bytearray: + script_hash = hash160(script) + assert len(script_hash) == 20 + return CScript([OP_HASH160, script_hash, OP_EQUAL]) + + def get_p2wsh_script_pubkey(self, script: bytearray) -> bytearray: + return CScript([OP_0, hashlib.sha256(script).digest()]) + def testBTCInterface(): print('TODO: testBTCInterface') diff --git a/basicswap/interface/contrib/pivx_test_framework/messages.py b/basicswap/interface/contrib/pivx_test_framework/messages.py index b5fd312..f6c84be 100755 --- a/basicswap/interface/contrib/pivx_test_framework/messages.py +++ b/basicswap/interface/contrib/pivx_test_framework/messages.py @@ -184,11 +184,18 @@ def ser_string_vector(l): return r +# Deserialize from bytes +def FromBytes(obj, tx_bytes): + obj.deserialize(BytesIO(tx_bytes)) + return obj + + # Deserialize from a hex string representation (eg from RPC) def FromHex(obj, hex_string): obj.deserialize(BytesIO(hex_str_to_bytes(hex_string))) return obj + # Convert a binary-serializable object to hex (eg for submission via RPC) def ToHex(obj): return bytes_to_hex_str(obj.serialize()) diff --git a/basicswap/interface/firo.py b/basicswap/interface/firo.py index 20a3aec..2490e68 100644 --- a/basicswap/interface/firo.py +++ b/basicswap/interface/firo.py @@ -132,26 +132,28 @@ class FIROInterface(BTCInterface): rv = self.rpc_callback('signrawtransaction', [tx.hex()]) return bytes.fromhex(rv['hex']) - def createRawSignedTransaction(self, addr_to, amount): + def createRawFundedTransaction(self, addr_to: str, amount: int, sub_fee: bool = False, lock_unspents: bool = True) -> str: txn = self.rpc_callback('createrawtransaction', [[], {addr_to: self.format_amount(amount)}]) - 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': True, + 'lockUnspents': lock_unspents, 'feeRate': fee_rate, } - txn_funded = self.rpc_callback('fundrawtransaction', [txn, options])['hex'] - txn_signed = self.rpc_callback('signrawtransaction', [txn_funded])['hex'] - return txn_signed + if sub_fee: + options['subtractFeeFromOutputs'] = [0,] + return self.rpc_callback('fundrawtransaction', [txn, options])['hex'] - def getScriptForPubkeyHash(self, pkh): - # Return P2WPKH nested in BIP16 P2SH + def createRawSignedTransaction(self, addr_to, amount) -> str: + txn_funded = self.createRawFundedTransaction(addr_to, amount) + return self.rpc_callback('signrawtransaction', [txn_funded])['hex'] + + def getScriptForPubkeyHash(self, pkh: bytes) -> bytearray: + # Return P2PKH return CScript([OP_DUP, OP_HASH160, pkh, OP_EQUALVERIFY, OP_CHECKSIG]) - def getScriptDest(self, script): + def getScriptDest(self, script: bytearray) -> bytearray: # P2WSH nested in BIP16_P2SH script_hash = hashlib.sha256(script).digest() diff --git a/basicswap/interface/pivx.py b/basicswap/interface/pivx.py index 62d9598..59cdca9 100644 --- a/basicswap/interface/pivx.py +++ b/basicswap/interface/pivx.py @@ -1,17 +1,20 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- -# Copyright (c) 2020 tecnovert +# Copyright (c) 2022 tecnovert # Distributed under the MIT software license, see the accompanying # file LICENSE or http://www.opensource.org/licenses/mit-license.php. +from io import BytesIO + from .btc import BTCInterface from basicswap.chainparams import Coins from basicswap.util.address import decodeAddress from .contrib.pivx_test_framework.messages import ( CBlock, ToHex, - FromHex) + FromHex, + CTransaction) class PIVXInterface(BTCInterface): @@ -19,19 +22,25 @@ class PIVXInterface(BTCInterface): def coin_type(): return Coins.PIVX - def createRawSignedTransaction(self, addr_to, amount): - txn = self.rpc_callback('createrawtransaction', [[], {addr_to: self.format_amount(amount)}]) + def signTxWithWallet(self, tx): + rv = self.rpc_callback('signrawtransaction', [tx.hex()]) + return bytes.fromhex(rv['hex']) + def createRawFundedTransaction(self, addr_to: str, amount: int, sub_fee: bool = False, lock_unspents: bool = True) -> str: + txn = self.rpc_callback('createrawtransaction', [[], {addr_to: self.format_amount(amount)}]) 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': True, + 'lockUnspents': lock_unspents, 'feeRate': fee_rate, } - txn_funded = self.rpc_callback('fundrawtransaction', [txn, options])['hex'] - txn_signed = self.rpc_callback('signrawtransaction', [txn_funded])['hex'] - return txn_signed + if sub_fee: + options['subtractFeeFromOutputs'] = [0,] + return self.rpc_callback('fundrawtransaction', [txn, options])['hex'] + + def createRawSignedTransaction(self, addr_to, amount) -> str: + txn_funded = self.createRawFundedTransaction(addr_to, amount) + return self.rpc_callback('signrawtransaction', [txn_funded])['hex'] def decodeAddress(self, address): return decodeAddress(address)[1:] @@ -65,3 +74,9 @@ class PIVXInterface(BTCInterface): def getSpendableBalance(self): return self.make_int(self.rpc_callback('getwalletinfo')['balance']) + + def loadTx(self, tx_bytes): + # Load tx from bytes to internal representation + tx = CTransaction() + tx.deserialize(BytesIO(tx_bytes)) + return tx diff --git a/basicswap/protocols/__init__.py b/basicswap/protocols/__init__.py index e69de29..13402f8 100644 --- a/basicswap/protocols/__init__.py +++ b/basicswap/protocols/__init__.py @@ -0,0 +1,12 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2022 tecnovert +# Distributed under the MIT software license, see the accompanying +# file LICENSE or http://www.opensource.org/licenses/mit-license.php. + + +class ProtocolInterface: + swap_type = None + + def getFundedInitiateTxTemplate(self, ci, amount: int, sub_fee: bool) -> bytes: + raise ValueError('base class') diff --git a/basicswap/protocols/atomic_swap_1.py b/basicswap/protocols/atomic_swap_1.py index 325f3ef..4917947 100644 --- a/basicswap/protocols/atomic_swap_1.py +++ b/basicswap/protocols/atomic_swap_1.py @@ -10,12 +10,17 @@ from basicswap.db import ( from basicswap.util import ( SerialiseNum, ) +from basicswap.util.script import ( + getP2WSH, +) from basicswap.script import ( OpCodes, ) from basicswap.basicswap_util import ( + SwapTypes, EventLogTypes, ) +from . import ProtocolInterface INITIATE_TX_TIMEOUT = 40 * 60 # TODO: make variable per coin ABS_LOCK_TIME_LEEWAY = 10 * 60 @@ -66,3 +71,43 @@ def redeemITx(self, bid_id, session): bid.initiate_tx.spend_txid = bytes.fromhex(txid) self.log.debug('Submitted initiate redeem txn %s to %s chain for bid %s', txid, ci_from.coin_name(), bid_id.hex()) self.logEvent(Concepts.BID, bid_id, EventLogTypes.ITX_REDEEM_PUBLISHED, '', session) + + +class AtomicSwapInterface(ProtocolInterface): + swap_type = SwapTypes.SELLER_FIRST + + def getMockScript(self) -> bytearray: + return bytearray([ + OpCodes.OP_RETURN, OpCodes.OP_1]) + + def getMockScriptScriptPubkey(self, ci) -> bytearray: + script = self.getMockScript() + return ci.get_p2wsh_script_pubkey(script) if ci._use_segwit else ci.get_p2sh_script_pubkey(script) + + def promoteMockTx(self, ci, mock_tx: bytes, script: bytearray) -> bytearray: + mock_txo_script = self.getMockScriptScriptPubkey(ci) + real_txo_script = ci.get_p2wsh_script_pubkey(script) if ci._use_segwit else ci.get_p2sh_script_pubkey(script) + + found: int = 0 + ctx = ci.loadTx(mock_tx) + for txo in ctx.vout: + if txo.scriptPubKey == mock_txo_script: + txo.scriptPubKey = real_txo_script + found += 1 + + if found < 1: + raise ValueError('Mocked output not found') + if found > 1: + raise ValueError('Too many mocked outputs found') + ctx.nLockTime = 0 + + funded_tx = ctx.serialize() + return ci.signTxWithWallet(funded_tx) + + def getFundedInitiateTxTemplate(self, ci, amount: int, sub_fee: bool) -> bytes: + + script = self.getMockScript() + addr_to = ci.encode_p2wsh(getP2WSH(script)) if ci._use_segwit else ci.encode_p2sh(script) + funded_tx = ci.createRawFundedTransaction(addr_to, amount, sub_fee, lock_unspents=False) + + return bytes.fromhex(funded_tx) diff --git a/basicswap/protocols/xmr_swap_1.py b/basicswap/protocols/xmr_swap_1.py index 3383a87..240ff07 100644 --- a/basicswap/protocols/xmr_swap_1.py +++ b/basicswap/protocols/xmr_swap_1.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright (c) 2020 tecnovert +# Copyright (c) 2020-2022 tecnovert # Distributed under the MIT software license, see the accompanying # file LICENSE or http://www.opensource.org/licenses/mit-license.php. @@ -14,8 +14,10 @@ from basicswap.chainparams import ( ) from basicswap.basicswap_util import ( KeyTypes, + SwapTypes, EventLogTypes, ) +from . import ProtocolInterface def addLockRefundSigs(self, xmr_swap, ci): @@ -84,3 +86,7 @@ def getChainBSplitKey(swap_client, bid, xmr_swap, offer): key_type = KeyTypes.KBSF if bid.was_sent else KeyTypes.KBSL return ci_to.encodeKey(swap_client.getPathKey(offer.coin_from, offer.coin_to, bid.created_at, xmr_swap.contract_count, key_type, True if offer.coin_to == Coins.XMR else False)) + + +class XmrSwapInterface(ProtocolInterface): + swap_type = SwapTypes.XMR_SWAP diff --git a/basicswap/script.py b/basicswap/script.py index 1418aac..3d6bae2 100644 --- a/basicswap/script.py +++ b/basicswap/script.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- -# Copyright (c) 2019 tecnovert +# Copyright (c) 2019-2022 tecnovert # Distributed under the MIT software license, see the accompanying # file LICENSE or http://www.opensource.org/licenses/mit-license.php. @@ -15,6 +15,7 @@ class OpCodes(IntEnum): OP_IF = 0x63, OP_ELSE = 0x67, OP_ENDIF = 0x68, + OP_RETURN = 0x6a, OP_DROP = 0x75, OP_DUP = 0x76, OP_SIZE = 0x82, diff --git a/doc/notes.md b/doc/notes.md index 37ba9c3..feb75a7 100644 --- a/doc/notes.md +++ b/doc/notes.md @@ -50,12 +50,28 @@ In chainclients.monero: On the remote machine open an ssh tunnel to port 18081: - ssh -R 18081:localhost:18081 -N user@LOCAL_NODE_IP + ssh -N -R 18081:localhost:18081 user@LOCAL_NODE_IP And start monerod -## Installing on windows natively +## SSH Tunnel to Remote BasicSwap Node + +While basicswap can be configured to host on an external interface: + +If not using docker by changing 'htmlhost' and 'wshost' in basicswap.json +For docker change 'HTML_PORT' and 'WS_PORT' in the .env file in the same dir as docker-compose.yml + +A better solution is to use ssh to forward the required ports from the machine running bascswap to the client. + + ssh -N -L 5555:localhost:12700 -L 11700:localhost:11700 BASICSWAP_HOST + +Run from the client machine (not running basicswap) will forward the basicswap ui on port 12700 to port 5555 +on the local machine and also the websocket port at 11700. +The ui port on the client machine can be anything but the websocket port must match 'wsport' in basicswap.json. + + +## Installing on Windows Natively This is not a supported installation method! diff --git a/tests/basicswap/common.py b/tests/basicswap/common.py index 162c2b3..6887962 100644 --- a/tests/basicswap/common.py +++ b/tests/basicswap/common.py @@ -261,18 +261,32 @@ def waitForNumSwapping(delay_event, port, bids, wait_for=60): raise ValueError('waitForNumSwapping failed') -def wait_for_balance(delay_event, url, balance_key, expect_amount, iterations=20, delay_time=3): +def wait_for_balance(delay_event, url, balance_key, expect_amount, iterations=20, delay_time=3) -> None: i = 0 while not delay_event.is_set(): rv_js = json.loads(urlopen(url).read()) if float(rv_js[balance_key]) >= expect_amount: - break + return delay_event.wait(delay_time) i += 1 if i > iterations: raise ValueError('Expect {} {}'.format(balance_key, expect_amount)) +def wait_for_unspent(delay_event, ci, expect_amount, iterations=20, delay_time=1) -> None: + logging.info(f'Waiting for unspent balance: {expect_amount}') + i = 0 + while not delay_event.is_set(): + unspent_addr = ci.getUnspentsByAddr() + for _, value in unspent_addr.items(): + if value >= expect_amount: + return + delay_event.wait(delay_time) + i += 1 + if i > iterations: + raise ValueError('wait_for_unspent {}'.format(expect_amount)) + + def delay_for(delay_event, delay_for=60): logging.info('Delaying for {} seconds.'.format(delay_for)) delay_event.wait(delay_for) diff --git a/tests/basicswap/test_run.py b/tests/basicswap/test_run.py index ee046b9..f823120 100644 --- a/tests/basicswap/test_run.py +++ b/tests/basicswap/test_run.py @@ -43,6 +43,7 @@ from tests.basicswap.common import ( wait_for_offer, wait_for_bid, wait_for_balance, + wait_for_unspent, wait_for_bid_tx_state, wait_for_in_progress, TEST_HTTP_PORT, @@ -496,6 +497,69 @@ class Test(BaseTest): assert (compare_bid_states(offerer_states, self.states_offerer[2]) is True) assert (compare_bid_states(bidder_states, self.states_bidder[2], exact_match=False) is True) + def test_14_sweep_balance(self): + logging.info('---------- Test sweep balance offer') + swap_clients = self.swap_clients + + # Disable staking + walletsettings = callnoderpc(2, 'walletsettings', ['stakingoptions', ]) + walletsettings['enabled'] = False + walletsettings = callnoderpc(2, 'walletsettings', ['stakingoptions', walletsettings]) + walletsettings = callnoderpc(2, 'walletsettings', ['stakingoptions', ]) + assert (walletsettings['stakingoptions']['enabled'] is False) + + # Prepare balance + js_w2 = read_json_api(1802, 'wallets') + if float(js_w2['PART']['balance']) < 100.0: + post_json = { + 'value': 100, + 'address': js_w2['PART']['deposit_address'], + 'subfee': False, + } + json_rv = read_json_api(TEST_HTTP_PORT + 0, 'wallets/part/withdraw', post_json) + assert (len(json_rv['txid']) == 64) + wait_for_balance(test_delay_event, 'http://127.0.0.1:1802/json/wallets/part', 'balance', 100.0) + + js_w2 = read_json_api(1802, 'wallets') + assert (float(js_w2['PART']['balance']) >= 100.0) + + js_w2 = read_json_api(1802, 'wallets') + post_json = { + 'value': float(js_w2['PART']['balance']), + 'address': read_json_api(1802, 'wallets/part/nextdepositaddr'), + 'subfee': True, + } + json_rv = read_json_api(TEST_HTTP_PORT + 2, 'wallets/part/withdraw', post_json) + wait_for_balance(test_delay_event, 'http://127.0.0.1:1802/json/wallets/part', 'balance', 10.0) + assert (len(json_rv['txid']) == 64) + + # Create prefunded ITX + ci = swap_clients[2].ci(Coins.PART) + pi = swap_clients[2].pi(SwapTypes.SELLER_FIRST) + js_w2 = read_json_api(1802, 'wallets') + swap_value = ci.make_int(js_w2['PART']['balance']) + + itx = pi.getFundedInitiateTxTemplate(ci, swap_value, True) + itx_decoded = ci.describeTx(itx.hex()) + value_after_subfee = ci.make_int(itx_decoded['vout'][0]['value']) + assert (value_after_subfee < swap_value) + swap_value = value_after_subfee + wait_for_unspent(test_delay_event, ci, swap_value) + + # Create swap with prefunded ITX + extra_options = {'prefunded_itx': itx} + offer_id = swap_clients[2].postOffer(Coins.PART, Coins.BTC, swap_value, 2 * COIN, swap_value, SwapTypes.SELLER_FIRST, extra_options=extra_options) + + wait_for_offer(test_delay_event, swap_clients[1], offer_id) + offer = swap_clients[1].getOffer(offer_id) + bid_id = swap_clients[1].postBid(offer_id, offer.amount_from) + + wait_for_bid(test_delay_event, swap_clients[2], bid_id) + swap_clients[2].acceptBid(bid_id) + + wait_for_bid(test_delay_event, swap_clients[2], bid_id, BidStates.SWAP_COMPLETED, wait_for=60) + wait_for_bid(test_delay_event, swap_clients[1], bid_id, BidStates.SWAP_COMPLETED, sent=True, wait_for=60) + def pass_99_delay(self): logging.info('Delay') for i in range(60 * 10):