From d527ec49740a4cc944de3ff78bd7f88e8445f180 Mon Sep 17 00:00:00 2001 From: tecnovert Date: Tue, 7 May 2024 00:20:22 +0200 Subject: [PATCH] Decred test_008_gettxout --- basicswap/basicswap.py | 13 +- basicswap/chainparams.py | 99 +--------- basicswap/interface/__init__.py | 13 -- basicswap/interface/base.py | 148 +++++++++++++++ basicswap/interface/btc.py | 93 +++++----- basicswap/interface/dcr/dcr.py | 258 ++++++++++++++++++++++++++- basicswap/interface/dcr/script.py | 1 - basicswap/interface/firo.py | 2 +- basicswap/interface/xmr.py | 11 +- basicswap/protocols/xmr_swap_1.py | 2 +- bin/basicswap_prepare.py | 4 +- tests/basicswap/extended/test_dcr.py | 193 ++++++++++++++++++-- tests/basicswap/test_btc_xmr.py | 4 +- 13 files changed, 652 insertions(+), 189 deletions(-) create mode 100644 basicswap/interface/base.py diff --git a/basicswap/basicswap.py b/basicswap/basicswap.py index f780a83..9e4a184 100644 --- a/basicswap/basicswap.py +++ b/basicswap/basicswap.py @@ -30,7 +30,7 @@ from typing import Optional from sqlalchemy.orm import sessionmaker, scoped_session from sqlalchemy.orm.session import close_all_sessions -from .interface import Curves +from .interface.base import Curves from .interface.part import PARTInterface, PARTInterfaceAnon, PARTInterfaceBlind from . import __version__ @@ -2334,9 +2334,7 @@ class BasicSwap(BaseApp): msg_buf.proof_signature = proof_sig if len(proof_utxos) > 0: - msg_buf.proof_utxos = bytes() - for utxo in proof_utxos: - msg_buf.proof_utxos += utxo[0] + utxo[1].to_bytes(2, 'big') + msg_buf.proof_utxos = ci_to.encodeProofUtxos(proof_utxos) contract_count = self.getNewContractId() msg_buf.pkhash_buyer = getKeyID(self.getContractPubkey(dt.datetime.fromtimestamp(now).date(), contract_count)) @@ -3356,10 +3354,11 @@ class BasicSwap(BaseApp): return None ci = self.ci(coin_type) - if coin_type in (Coins.NAV, ): + if coin_type in (Coins.NAV, Coins.DCR): wif_prefix = chainparams[coin_type][self.chain]['key_prefix'] prevout = ci.find_prevout_info(txn, txn_script) else: + # TODO: Sign in bsx for all coins wif_prefix = chainparams[Coins.PART][self.chain]['key_prefix'] txjs = self.callcoinrpc(Coins.PART, 'decoderawtransaction', [txn]) if ci.using_segwit(): @@ -3414,7 +3413,7 @@ class BasicSwap(BaseApp): options = {} if self.coin_clients[coin_type]['use_segwit']: options['force_segwit'] = True - if coin_type in (Coins.NAV, ): + if coin_type in (Coins.NAV, Coins.DCR): refund_sig = ci.getTxSignature(refund_txn, prevout, privkey) else: refund_sig = self.callcoinrpc(Coins.PART, 'createsignaturewithkey', [refund_txn, prevout, privkey, 'ALL', options]) @@ -3432,7 +3431,7 @@ class BasicSwap(BaseApp): script += format(OpCodes.OP_PUSHDATA1, '02x') + format(len(txn_script), '02x') + txn_script.hex() refund_txn = ci.setTxScriptSig(bytes.fromhex(refund_txn), 0, bytes.fromhex(script)).hex() - if coin_type in (Coins.NAV, ): + if coin_type in (Coins.NAV, Coins.DCR): # Only checks signature ro = ci.verifyRawTransaction(refund_txn, [prevout]) else: diff --git a/basicswap/chainparams.py b/basicswap/chainparams.py index 5dd0a2c..0d967e8 100644 --- a/basicswap/chainparams.py +++ b/basicswap/chainparams.py @@ -4,14 +4,9 @@ # Distributed under the MIT software license, see the accompanying # file LICENSE or http://www.opensource.org/licenses/mit-license.php. -import threading - from enum import IntEnum from .util import ( COIN, - make_int, - format_amount, - TemporaryError, ) XMR_COIN = 10 ** 12 @@ -180,11 +175,11 @@ chainparams = { 'max_amount': 100000 * COIN, 'name': 'testnet3', }, - 'regtest': { + 'regtest': { # simnet 'rpcport': 18656, - 'pubkey_address': 0x0e00, - 'script_address': 0x0ddb, - 'key_prefix': 0x22fe, + 'pubkey_address': 0x0e91, + 'script_address': 0x0e6c, + 'key_prefix': 0x2307, 'bip44': 1, 'min_amount': 1000, 'max_amount': 100000 * COIN, @@ -422,89 +417,3 @@ def getCoinIdFromTicker(ticker): return ticker_map[ticker.lower()] except Exception: raise ValueError('Unknown coin') - - -class CoinInterface: - def __init__(self, network): - self.setDefaults() - self._network = network - self._mx_wallet = threading.Lock() - - def setDefaults(self): - self._unknown_wallet_seed = True - self._restore_height = None - - def make_int(self, amount_in: int, r: int = 0) -> int: - return make_int(amount_in, self.exp(), r=r) - - def format_amount(self, amount_in, conv_int=False, r=0): - amount_int = make_int(amount_in, self.exp(), r=r) if conv_int else amount_in - return format_amount(amount_int, self.exp()) - - def coin_name(self) -> str: - coin_chainparams = chainparams[self.coin_type()] - if coin_chainparams.get('use_ticker_as_name', False): - return coin_chainparams['ticker'] - return coin_chainparams['name'].capitalize() - - def ticker(self) -> str: - ticker = chainparams[self.coin_type()]['ticker'] - if self._network == 'testnet': - ticker = 't' + ticker - elif self._network == 'regtest': - ticker = 'rt' + ticker - return ticker - - def getExchangeTicker(self, exchange_name: str) -> str: - return chainparams[self.coin_type()]['ticker'] - - def getExchangeName(self, exchange_name: str) -> str: - return chainparams[self.coin_type()]['name'] - - def ticker_mainnet(self) -> str: - ticker = chainparams[self.coin_type()]['ticker'] - return ticker - - def min_amount(self) -> int: - return chainparams[self.coin_type()][self._network]['min_amount'] - - def max_amount(self) -> int: - return chainparams[self.coin_type()][self._network]['max_amount'] - - def setWalletSeedWarning(self, value: bool) -> None: - self._unknown_wallet_seed = value - - def setWalletRestoreHeight(self, value: int) -> None: - self._restore_height = value - - def knownWalletSeed(self) -> bool: - return not self._unknown_wallet_seed - - def chainparams(self): - return chainparams[self.coin_type()] - - def chainparams_network(self): - return chainparams[self.coin_type()][self._network] - - def has_segwit(self) -> bool: - return chainparams[self.coin_type()].get('has_segwit', True) - - def is_transient_error(self, ex) -> bool: - if isinstance(ex, TemporaryError): - return True - str_error: str = str(ex).lower() - if 'not enough unlocked money' in str_error: - return True - if 'no unlocked balance' in str_error: - return True - if 'transaction was rejected by daemon' in str_error: - return True - if 'invalid unlocked_balance' in str_error: - return True - if 'daemon is busy' in str_error: - return True - if 'timed out' in str_error: - return True - if 'request-sent' in str_error: - return True - return False diff --git a/basicswap/interface/__init__.py b/basicswap/interface/__init__.py index bb1bde0..e69de29 100644 --- a/basicswap/interface/__init__.py +++ b/basicswap/interface/__init__.py @@ -1,13 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -# Copyright (c) 2023 tecnovert -# Distributed under the MIT software license, see the accompanying -# file LICENSE or http://www.opensource.org/licenses/mit-license.php. - -from enum import IntEnum - - -class Curves(IntEnum): - secp256k1 = 1 - ed25519 = 2 diff --git a/basicswap/interface/base.py b/basicswap/interface/base.py new file mode 100644 index 0000000..da4dea1 --- /dev/null +++ b/basicswap/interface/base.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +# Copyright (c) 2024 tecnovert +# Distributed under the MIT software license, see the accompanying +# file LICENSE or http://www.opensource.org/licenses/mit-license.php. + +import threading + +from enum import IntEnum + +from basicswap.chainparams import ( + chainparams, +) +from basicswap.util import ( + ensure, + i2b, b2i, + make_int, + format_amount, + TemporaryError, +) +from basicswap.util.ecc import ( + ep, + getSecretInt, +) +from coincurve.dleag import ( + verify_secp256k1_point +) +from coincurve.keys import ( + PublicKey, +) + + +class Curves(IntEnum): + secp256k1 = 1 + ed25519 = 2 + + +class CoinInterface: + def __init__(self, network): + self.setDefaults() + self._network = network + self._mx_wallet = threading.Lock() + + def setDefaults(self): + self._unknown_wallet_seed = True + self._restore_height = None + + def make_int(self, amount_in: int, r: int = 0) -> int: + return make_int(amount_in, self.exp(), r=r) + + def format_amount(self, amount_in, conv_int=False, r=0): + amount_int = make_int(amount_in, self.exp(), r=r) if conv_int else amount_in + return format_amount(amount_int, self.exp()) + + def coin_name(self) -> str: + coin_chainparams = chainparams[self.coin_type()] + if coin_chainparams.get('use_ticker_as_name', False): + return coin_chainparams['ticker'] + return coin_chainparams['name'].capitalize() + + def ticker(self) -> str: + ticker = chainparams[self.coin_type()]['ticker'] + if self._network == 'testnet': + ticker = 't' + ticker + elif self._network == 'regtest': + ticker = 'rt' + ticker + return ticker + + def getExchangeTicker(self, exchange_name: str) -> str: + return chainparams[self.coin_type()]['ticker'] + + def getExchangeName(self, exchange_name: str) -> str: + return chainparams[self.coin_type()]['name'] + + def ticker_mainnet(self) -> str: + ticker = chainparams[self.coin_type()]['ticker'] + return ticker + + def min_amount(self) -> int: + return chainparams[self.coin_type()][self._network]['min_amount'] + + def max_amount(self) -> int: + return chainparams[self.coin_type()][self._network]['max_amount'] + + def setWalletSeedWarning(self, value: bool) -> None: + self._unknown_wallet_seed = value + + def setWalletRestoreHeight(self, value: int) -> None: + self._restore_height = value + + def knownWalletSeed(self) -> bool: + return not self._unknown_wallet_seed + + def chainparams(self): + return chainparams[self.coin_type()] + + def chainparams_network(self): + return chainparams[self.coin_type()][self._network] + + def has_segwit(self) -> bool: + return chainparams[self.coin_type()].get('has_segwit', True) + + def is_transient_error(self, ex) -> bool: + if isinstance(ex, TemporaryError): + return True + str_error: str = str(ex).lower() + if 'not enough unlocked money' in str_error: + return True + if 'no unlocked balance' in str_error: + return True + if 'transaction was rejected by daemon' in str_error: + return True + if 'invalid unlocked_balance' in str_error: + return True + if 'daemon is busy' in str_error: + return True + if 'timed out' in str_error: + return True + if 'request-sent' in str_error: + return True + return False + + def setConfTarget(self, new_conf_target: int) -> None: + ensure(new_conf_target >= 1 and new_conf_target < 33, 'Invalid conf_target value') + self._conf_target = new_conf_target + + def walletRestoreHeight(self) -> int: + return self._restore_height + + +class Secp256k1Interface(CoinInterface): + @staticmethod + def curve_type(): + return Curves.secp256k1 + + def getNewSecretKey(self) -> bytes: + return i2b(getSecretInt()) + + def getPubkey(self, privkey): + return PublicKey.from_secret(privkey).format() + + def verifyKey(self, k: bytes) -> bool: + i = b2i(k) + return (i < ep.o and i > 0) + + def verifyPubkey(self, pubkey_bytes: bytes) -> bool: + return verify_secp256k1_point(pubkey_bytes) diff --git a/basicswap/interface/btc.py b/basicswap/interface/btc.py index f096f6a..02dec4f 100644 --- a/basicswap/interface/btc.py +++ b/basicswap/interface/btc.py @@ -5,24 +5,32 @@ # Distributed under the MIT software license, see the accompanying # file LICENSE or http://www.opensource.org/licenses/mit-license.php. -import json import base64 import hashlib +import json import logging import traceback -from io import BytesIO -from basicswap.contrib.test_framework import segwit_addr +from io import BytesIO -from basicswap.interface import ( - Curves) +from basicswap.basicswap_util import ( + getVoutByAddress, + getVoutByScriptPubKey, +) +from basicswap.contrib.test_framework import ( + segwit_addr, +) +from basicswap.interface.base import ( + Secp256k1Interface, +) from basicswap.util import ( ensure, - b2h, i2b, b2i, i2h) + b2h, i2b, b2i, i2h, +) from basicswap.util.ecc import ( ep, pointToCPK, CPKToPoint, - getSecretInt) +) from basicswap.util.script import ( decodeScriptNum, getCompactSizeLen, @@ -42,14 +50,14 @@ from basicswap.util.crypto import ( ) from coincurve.keys import ( PrivateKey, - PublicKey) -from coincurve.dleag import ( - verify_secp256k1_point) + PublicKey, +) from coincurve.ecdsaotves import ( ecdsaotves_enc_sign, ecdsaotves_enc_verify, ecdsaotves_dec_sig, - ecdsaotves_rec_enc_key) + ecdsaotves_rec_enc_key +) from basicswap.contrib.test_framework.messages import ( COIN, @@ -70,12 +78,13 @@ from basicswap.contrib.test_framework.script import ( OP_DROP, OP_HASH160, OP_EQUAL, SIGHASH_ALL, - SegwitV0SignatureHash) - + SegwitV0SignatureHash, +) from basicswap.basicswap_util import ( - TxLockTypes) + TxLockTypes +) -from basicswap.chainparams import CoinInterface, Coins +from basicswap.chainparams import Coins from basicswap.rpc import make_rpc_func, openrpc @@ -111,25 +120,6 @@ def find_vout_for_address_from_txobj(tx_obj, addr: str) -> int: raise RuntimeError("Vout not found for address: txid={}, addr={}".format(tx_obj['txid'], addr)) -class Secp256k1Interface(CoinInterface): - @staticmethod - def curve_type(): - return Curves.secp256k1 - - def getNewSecretKey(self) -> bytes: - return i2b(getSecretInt()) - - def getPubkey(self, privkey): - return PublicKey.from_secret(privkey).format() - - def verifyKey(self, k: bytes) -> bool: - i = b2i(k) - return (i < ep.o and i > 0) - - def verifyPubkey(self, pubkey_bytes: bytes) -> bool: - return verify_secp256k1_point(pubkey_bytes) - - class BTCInterface(Secp256k1Interface): @staticmethod @@ -185,7 +175,7 @@ class BTCInterface(Secp256k1Interface): @staticmethod def getExpectedSequence(lockType: int, lockVal: int) -> int: - assert (lockVal >= 1), 'Bad lockVal' + ensure(lockVal >= 1, 'Bad lockVal') if lockType == TxLockTypes.SEQUENCE_LOCK_BLOCKS: return lockVal if lockType == TxLockTypes.SEQUENCE_LOCK_TIME: @@ -271,10 +261,6 @@ class BTCInterface(Secp256k1Interface): def close_rpc(self, rpc_conn): rpc_conn.close() - def setConfTarget(self, new_conf_target: int) -> None: - ensure(new_conf_target >= 1 and new_conf_target < 33, 'Invalid conf_target value') - self._conf_target = new_conf_target - def testDaemonRPC(self, with_wallet=True) -> None: self.rpc_wallet('getwalletinfo' if with_wallet else 'getblockchaininfo') @@ -321,9 +307,6 @@ class BTCInterface(Secp256k1Interface): rv['locked_utxos'] = len(self.rpc_wallet('listlockunspent')) return rv - def walletRestoreHeight(self) -> int: - return self._restore_height - def getWalletRestoreHeight(self) -> int: start_time = self.rpc_wallet('getwalletinfo')['keypoololdest'] @@ -1249,7 +1232,7 @@ class BTCInterface(Secp256k1Interface): 'vout': utxo['vout']}) return rv, chain_height - def withdrawCoin(self, value, addr_to, subfee): + def withdrawCoin(self, value: float, addr_to: str, subfee: bool): params = [addr_to, value, '', '', subfee, True, self._conf_target] return self.rpc_wallet('sendtoaddress', params) @@ -1435,6 +1418,12 @@ class BTCInterface(Secp256k1Interface): prove_utxos = [] # TODO: Send specific utxos return (sign_for_addr, signature, prove_utxos) + def encodeProofUtxos(self, proof_utxos): + packed_utxos = bytes() + for utxo in proof_utxos: + packed_utxos += utxo[0] + utxo[1].to_bytes(2, 'big') + return packed_utxos + def decodeProofUtxos(self, msg_utxos): proof_utxos = [] if len(msg_utxos) > 0: @@ -1555,6 +1544,24 @@ class BTCInterface(Secp256k1Interface): tx_vsize += 323 if redeem else 287 return tx_vsize + def find_prevout_info(self, txn_hex: str, txn_script: bytes): + txjs = self.rpc('decoderawtransaction', [txn_hex]) + + if self.using_segwit(): + p2wsh = self.getScriptDest(txn_script) + n = getVoutByScriptPubKey(txjs, p2wsh.hex()) + else: + addr_to = self.encode_p2sh(txn_script) + n = getVoutByAddress(txjs, addr_to) + + return { + 'txid': txjs['txid'], + 'vout': n, + 'scriptPubKey': txjs['vout'][n]['scriptPubKey']['hex'], + 'redeemScript': txn_script.hex(), + 'amount': txjs['vout'][n]['value'] + } + def testBTCInterface(): print('TODO: testBTCInterface') diff --git a/basicswap/interface/dcr/dcr.py b/basicswap/interface/dcr/dcr.py index b20c656..8456cd9 100644 --- a/basicswap/interface/dcr/dcr.py +++ b/basicswap/interface/dcr/dcr.py @@ -5,10 +5,20 @@ # Distributed under the MIT software license, see the accompanying # file LICENSE or http://www.opensource.org/licenses/mit-license.php. +import base64 +import hashlib import logging +import random +from basicswap.basicswap_util import ( + getVoutByScriptPubKey, + TxLockTypes +) from basicswap.chainparams import Coins from basicswap.interface.btc import Secp256k1Interface +from basicswap.util import ( + ensure, +) from basicswap.util.address import ( b58decode, b58encode, @@ -18,6 +28,9 @@ from basicswap.util.crypto import ( hash160, ripemd160, ) +from basicswap.util.script import ( + SerialiseNumCompact, +) from basicswap.util.extkey import ExtKeyPair from basicswap.util.integer import encode_varint from basicswap.interface.dcr.rpc import make_rpc_func @@ -25,10 +38,15 @@ from .messages import CTransaction, CTxOut, SigHashType, TxSerializeType from .script import push_script_data, OP_HASH160, OP_EQUAL, OP_DUP, OP_EQUALVERIFY, OP_CHECKSIG from coincurve.keys import ( - PrivateKey + PrivateKey, + PublicKey, ) +SEQUENCE_LOCKTIME_GRANULARITY = 9 # 512 seconds +SEQUENCE_LOCKTIME_TYPE_FLAG = (1 << 22) +SEQUENCE_LOCKTIME_MASK = 0x0000f + SigHashSerializePrefix: int = 1 SigHashSerializeWitness: int = 3 @@ -136,6 +154,20 @@ class DCRInterface(Secp256k1Interface): def txoType(): return CTxOut + @staticmethod + def getExpectedSequence(lockType: int, lockVal: int) -> int: + ensure(lockVal >= 1, 'Bad lockVal') + if lockType == TxLockTypes.SEQUENCE_LOCK_BLOCKS: + return lockVal + if lockType == TxLockTypes.SEQUENCE_LOCK_TIME: + secondsLocked = lockVal + # Ensure the locked time is never less than lockVal + if secondsLocked % (1 << SEQUENCE_LOCKTIME_GRANULARITY) != 0: + secondsLocked += (1 << SEQUENCE_LOCKTIME_GRANULARITY) + secondsLocked >>= SEQUENCE_LOCKTIME_GRANULARITY + return secondsLocked | SEQUENCE_LOCKTIME_TYPE_FLAG + raise ValueError('Unknown lock type') + def __init__(self, coin_settings, network, swap_client=None): super().__init__(network) self._rpc_host = coin_settings.get('rpchost', '127.0.0.1') @@ -148,6 +180,8 @@ class DCRInterface(Secp256k1Interface): self.rpc_wallet = make_rpc_func(coin_settings['walletrpcport'], self._rpcauth, host=self._rpc_host) else: self.rpc_wallet = None + self.blocks_confirmed = coin_settings['blocks_confirmed'] + self.setConfTarget(coin_settings['conf_target']) self._use_segwit = coin_settings['use_segwit'] @@ -161,6 +195,13 @@ class DCRInterface(Secp256k1Interface): checksum = blake256(blake256(data)) return b58encode(data + checksum[0:4]) + def sh_to_address(self, sh: bytes) -> str: + assert (len(sh) == 20) + prefix = self.chainparams_network()['script_address'] + data = prefix.to_bytes(2, 'big') + sh + checksum = blake256(blake256(data)) + return b58encode(data + checksum[0:4]) + def decode_address(self, address: str) -> bytes: addr_data = b58decode(address) if addr_data is None: @@ -198,7 +239,18 @@ class DCRInterface(Secp256k1Interface): return self._use_segwit def getWalletInfo(self): + rv = {} rv = self.rpc_wallet('getinfo') + wi = self.rpc_wallet('walletinfo') + balances = self.rpc_wallet('getbalance') + + default_account_bal = balances['balances'][0] # 0 always default? + rv['balance'] = default_account_bal['spendable'] + rv['unconfirmed_balance'] = default_account_bal['unconfirmed'] + rv['immature_balance'] = default_account_bal['immaturecoinbaserewards'] + default_account_bal['immaturestakegeneration'] + rv['encrypted'] = True + rv['locked'] = True if wi['unlocked'] is False else False + return rv def getSeedHash(self, seed: bytes) -> bytes: @@ -251,6 +303,13 @@ class DCRInterface(Secp256k1Interface): tx = self.loadTx(tx_bytes) return tx.serialize(TxSerializeType.NoWitness) + def getTxSignature(self, tx_hex: str, prevout_data, key_wif: str) -> str: + sig_type, key = self.decodeKey(key_wif) + redeem_script = bytes.fromhex(prevout_data['redeemScript']) + sig = self.signTx(key, bytes.fromhex(tx_hex), 0, redeem_script, self.make_int(prevout_data['amount'])) + + return sig.hex() + def getScriptDest(self, script: bytes) -> bytes: # P2SH script_hash = self.pkh(script) @@ -258,8 +317,205 @@ class DCRInterface(Secp256k1Interface): return OP_HASH160.to_bytes(1) + len(script_hash).to_bytes(1) + script_hash + OP_EQUAL.to_bytes(1) + def encodeScriptDest(self, script_dest: bytes) -> str: + script_hash = script_dest[2:-1] # Extract hash from script + return self.sh_to_address(script_hash) + def getPubkeyHashDest(self, pkh: bytes) -> bytes: # P2PKH assert len(pkh) == 20 return OP_DUP.to_bytes(1) + OP_HASH160.to_bytes(1) + len(pkh).to_bytes(1) + pkh + OP_EQUALVERIFY.to_bytes(1) + OP_CHECKSIG.to_bytes(1) + + def get_fee_rate(self, conf_target: int = 2) -> (float, str): + chain_client_settings = self._sc.getChainClientSettings(self.coin_type()) # basicswap.json + override_feerate = chain_client_settings.get('override_feerate', None) + if override_feerate: + self._log.debug('Fee rate override used for %s: %f', self.coin_name(), override_feerate) + return override_feerate, 'override_feerate' + + min_relay_fee = chain_client_settings.get('min_relay_fee', None) + + def try_get_fee_rate(self, conf_target): + # TODO: How to estimate required fee? + try: + fee_rate: float = self.rpc_wallet('walletinfo')['txfee'] + assert (fee_rate > 0.0), 'Non positive feerate' + return fee_rate, 'paytxfee' + except Exception: + fee_rate: float = self.rpc('getnetworkinfo')['relayfee'] + return fee_rate, 'relayfee' + + fee_rate, rate_src = try_get_fee_rate(self, conf_target) + if min_relay_fee and min_relay_fee > fee_rate: + self._log.warning('Feerate {} ({}) is below min relay fee {} for {}'.format(self.format_amount(fee_rate, True, 1), rate_src, self.format_amount(min_relay_fee, True, 1), self.coin_name())) + return min_relay_fee, 'min_relay_fee' + return fee_rate, rate_src + + def getNewAddress(self, use_segwit: bool = True, label: str = 'swap_receive') -> str: + return self.rpc_wallet('getnewaddress') + + def getProofOfFunds(self, amount_for, extra_commit_bytes): + # TODO: Lock unspent and use same output/s to fund bid + + unspents_by_addr = dict() + unspents = self.rpc_wallet('listunspent') + if unspents is None: + unspents = [] + for u in unspents: + if u['spendable'] is not True: + continue + if u['address'] not in unspents_by_addr: + unspents_by_addr[u['address']] = {'total': 0, 'utxos': []} + utxo_amount: int = self.make_int(u['amount'], r=1) + unspents_by_addr[u['address']]['total'] += utxo_amount + unspents_by_addr[u['address']]['utxos'].append((utxo_amount, u['txid'], u['vout'], u['tree'])) + + max_utxos: int = 4 + + viable_addrs = [] + for addr, data in unspents_by_addr.items(): + if data['total'] >= amount_for: + # Sort from largest to smallest amount + sorted_utxos = sorted(data['utxos'], key=lambda x: x[0]) + + # Max outputs required to reach amount_for + utxos_req: int = 0 + sum_value: int = 0 + for utxo in sorted_utxos: + sum_value += utxo[0] + utxos_req += 1 + if sum_value >= amount_for: + break + + if utxos_req <= max_utxos: + viable_addrs.append(addr) + continue + + ensure(len(viable_addrs) > 0, 'Could not find address with enough funds for proof') + + sign_for_addr: str = random.choice(viable_addrs) + self._log.debug('sign_for_addr %s', sign_for_addr) + + prove_utxos = [] + sorted_utxos = sorted(unspents_by_addr[sign_for_addr]['utxos'], key=lambda x: x[0]) + + hasher = hashlib.sha256() + sum_value: int = 0 + for utxo in sorted_utxos: + sum_value += utxo[0] + outpoint = (bytes.fromhex(utxo[1]), utxo[2], utxo[3]) + prove_utxos.append(outpoint) + hasher.update(outpoint[0]) + hasher.update(outpoint[1].to_bytes(2, 'big')) + hasher.update(outpoint[2].to_bytes(1)) + if sum_value >= amount_for: + break + utxos_hash = hasher.digest() + + signature = self.rpc_wallet('signmessage', [sign_for_addr, sign_for_addr + '_swap_proof_' + utxos_hash.hex() + extra_commit_bytes.hex()]) + + return (sign_for_addr, signature, prove_utxos) + + def withdrawCoin(self, value: float, addr_to: str, subfee: bool = False) -> str: + if subfee: + raise ValueError('TODO') + params = [addr_to, value] + return self.rpc_wallet('sendtoaddress', params) + + def isAddressMine(self, address: str, or_watch_only: bool = False) -> bool: + addr_info = self.rpc('validateaddress', [address]) + return addr_info.get('ismine', False) + + def encodeProofUtxos(self, proof_utxos): + packed_utxos = bytes() + for utxo in proof_utxos: + packed_utxos += utxo[0] + utxo[1].to_bytes(2, 'big') + utxo[2].to_bytes(1) + return packed_utxos + + def decodeProofUtxos(self, msg_utxos): + proof_utxos = [] + if len(msg_utxos) > 0: + num_utxos = len(msg_utxos) // 34 + p: int = 0 + for i in range(num_utxos): + proof_utxos.append((msg_utxos[p: p + 32], int.from_bytes(msg_utxos[p + 32: p + 34], 'big'), msg_utxos[p + 34])) + p += 35 + return proof_utxos + + def verifyProofOfFunds(self, address: str, signature: bytes, utxos, extra_commit_bytes: bytes): + hasher = hashlib.sha256() + sum_value: int = 0 + for outpoint in utxos: + hasher.update(outpoint[0]) + hasher.update(outpoint[1].to_bytes(2, 'big')) + hasher.update(outpoint[2].to_bytes(1)) + utxos_hash = hasher.digest() + + passed = self.verifyMessage(address, address + '_swap_proof_' + utxos_hash.hex() + extra_commit_bytes.hex(), signature) + ensure(passed is True, 'Proof of funds signature invalid') + + sum_value: int = 0 + for outpoint in utxos: + txout = self.rpc('gettxout', [outpoint[0].hex(), outpoint[1], outpoint[2]]) + sum_value += self.make_int(txout['value']) + + return sum_value + + 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'] + + message_bytes = SerialiseNumCompact(len(message_magic)) + bytes(message_magic, 'utf-8') + SerialiseNumCompact(len(message)) + bytes(message, 'utf-8') + message_hash = blake256(message_bytes) + signature_bytes = base64.b64decode(signature) + rec_id = (signature_bytes[0] - 27) & 3 + signature_bytes = signature_bytes[1:] + bytes((rec_id,)) + try: + pubkey = PublicKey.from_signature_and_message(signature_bytes, message_hash, hasher=None) + except Exception as e: + self._log.info('verifyMessage failed: ' + str(e)) + return False + + address_hash = self.decode_address(address)[2:] + pubkey_hash = ripemd160(blake256(pubkey.format())) + + return True if address_hash == pubkey_hash else False + + def signTxWithWallet(self, tx) -> bytes: + return bytes.fromhex(self.rpc('signrawtransaction', [tx.hex()])['hex']) + + def createRawFundedTransaction(self, addr_to: str, amount: int, sub_fee: bool = False, lock_unspents: bool = True) -> 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) + self._log.debug(f'Fee rate: {fee_rate}, source: {fee_src}, block target: {self._conf_target}') + options = { + 'lockUnspents': lock_unspents, + 'feeRate': fee_rate, + } + if sub_fee: + options['subtractFeeFromOutputs'] = [0,] + return self.rpc_wallet('fundrawtransaction', [txn, 'default', options])['hex'] + + def createRawSignedTransaction(self, addr_to, amount) -> str: + txn_funded = self.createRawFundedTransaction(addr_to, amount) + return self.rpc_wallet('signrawtransaction', [txn_funded])['hex'] + + def getLockTxHeight(self, txid, dest_address, bid_amount, rescan_from, find_index: bool = False): + self._log.debug('TODO: getLockTxHeight') + return None + + def find_prevout_info(self, txn_hex: str, txn_script: bytes): + txjs = self.rpc('decoderawtransaction', [txn_hex]) + n = getVoutByScriptPubKey(txjs, self.getScriptDest(txn_script).hex()) + + return { + 'txid': txjs['txid'], + 'vout': n, + 'scriptPubKey': txjs['vout'][n]['scriptPubKey']['hex'], + 'redeemScript': txn_script.hex(), + 'amount': txjs['vout'][n]['value'] + } diff --git a/basicswap/interface/dcr/script.py b/basicswap/interface/dcr/script.py index 78bb6f5..bdda286 100644 --- a/basicswap/interface/dcr/script.py +++ b/basicswap/interface/dcr/script.py @@ -42,5 +42,4 @@ def push_script_data(data_array: bytearray, data: bytes) -> None: else: data_array += bytes((OP_PUSHDATA4,)) + len_data.to_bytes(4, 'little') - print('[rm] data_array', (data_array + data).hex()) data_array += data diff --git a/basicswap/interface/firo.py b/basicswap/interface/firo.py index a750777..a99b046 100644 --- a/basicswap/interface/firo.py +++ b/basicswap/interface/firo.py @@ -5,8 +5,8 @@ # Distributed under the MIT software license, see the accompanying # file LICENSE or http://www.opensource.org/licenses/mit-license.php. -import random import hashlib +import random from .btc import BTCInterface, find_vout_for_address_from_txobj from basicswap.util import ( diff --git a/basicswap/interface/xmr.py b/basicswap/interface/xmr.py index da5b935..104c588 100644 --- a/basicswap/interface/xmr.py +++ b/basicswap/interface/xmr.py @@ -24,8 +24,9 @@ from coincurve.dleag import ( verify_ed25519_point, ) -from basicswap.interface import ( - Curves) +from basicswap.interface.base import ( + Curves, +) from basicswap.util import ( i2b, b2i, b2h, dumpj, @@ -36,7 +37,8 @@ from basicswap.util.network import ( from basicswap.rpc_xmr import ( make_xmr_rpc_func, make_xmr_rpc2_func) -from basicswap.chainparams import XMR_COIN, CoinInterface, Coins +from basicswap.chainparams import XMR_COIN, Coins +from basicswap.interface.base import CoinInterface class XMRInterface(CoinInterface): @@ -239,9 +241,6 @@ class XMRInterface(CoinInterface): rv['locked'] = False return rv - def walletRestoreHeight(self): - return self._restore_height - def getMainWalletAddress(self) -> str: with self._mx_wallet: self.openWallet(self._wallet_filename) diff --git a/basicswap/protocols/xmr_swap_1.py b/basicswap/protocols/xmr_swap_1.py index b21dbca..70acc53 100644 --- a/basicswap/protocols/xmr_swap_1.py +++ b/basicswap/protocols/xmr_swap_1.py @@ -9,7 +9,7 @@ from sqlalchemy.orm import scoped_session from basicswap.util import ( ensure, ) -from basicswap.interface import Curves +from basicswap.interface.base import Curves from basicswap.chainparams import ( Coins, ) diff --git a/bin/basicswap_prepare.py b/bin/basicswap_prepare.py index 47df9d6..e552536 100755 --- a/bin/basicswap_prepare.py +++ b/bin/basicswap_prepare.py @@ -1182,7 +1182,7 @@ def finalise_daemon(d): d.handle.send_signal(signal.CTRL_C_EVENT if os.name == 'nt' else signal.SIGINT) d.handle.wait(timeout=120) except Exception as e: - logging.info(f'Error {e} for process {d.pid}') + logging.info(f'Error {e} for process {d.handle.pid}') for fp in [d.handle.stdout, d.handle.stderr, d.handle.stdin] + d.files: if fp: fp.close() @@ -1262,7 +1262,7 @@ def initialise_wallets(particl_wallet_mnemonic, with_coins, data_dir, settings, coin_args += ['-hdseed={}'.format(swap_client.getWalletKey(Coins.FIRO, 1).hex())] daemons.append(startDaemon(coin_settings['datadir'], coin_settings['bindir'], filename, daemon_args + coin_args)) - swap_client.setDaemonPID(c, daemons[-1].pid) + swap_client.setDaemonPID(c, daemons[-1].handle.pid) swap_client.setCoinRunParams(c) swap_client.createCoinInterface(c) diff --git a/tests/basicswap/extended/test_dcr.py b/tests/basicswap/extended/test_dcr.py index 689752c..034e850 100644 --- a/tests/basicswap/extended/test_dcr.py +++ b/tests/basicswap/extended/test_dcr.py @@ -7,8 +7,8 @@ import logging import os -import subprocess import select +import subprocess import unittest import basicswap.config as cfg @@ -29,11 +29,12 @@ from basicswap.interface.dcr.messages import ( from tests.basicswap.common import ( stopDaemons, waitForRPC, + wait_for_balance, ) from tests.basicswap.util import ( + read_json_api, REQUIRED_SETTINGS, ) - from tests.basicswap.test_xmr import BaseTest, test_delay_event from basicswap.interface.dcr import DCRInterface from basicswap.interface.dcr.messages import CTransaction, CTxIn, COutPoint @@ -107,7 +108,7 @@ def prepareDCDDataDir(datadir, node_id, conf_file, dir_prefix, num_nodes=3): class Test(BaseTest): __test__ = True - test_coin_from = Coins.DCR + test_coin = Coins.DCR dcr_daemons = [] start_ltc_nodes = False start_xmr_nodes = False @@ -122,7 +123,7 @@ class Test(BaseTest): @classmethod def prepareExtraCoins(cls): if not cls.restore_instance: - ci0 = cls.swap_clients[0].ci(cls.test_coin_from) + ci0 = cls.swap_clients[0].ci(cls.test_coin) assert (ci0.rpc_wallet('getnewaddress') == cls.dcr_mining_addr) cls.dcr_ticket_account = ci0.rpc_wallet('getaccount', [cls.dcr_mining_addr, ]) ci0.rpc('generate', [110,]) @@ -140,7 +141,7 @@ class Test(BaseTest): @classmethod def coins_loop(cls): super(Test, cls).coins_loop() - ci0 = cls.swap_clients[0].ci(cls.test_coin_from) + ci0 = cls.swap_clients[0].ci(cls.test_coin) num_passed: int = 0 for i in range(5): @@ -148,7 +149,10 @@ class Test(BaseTest): ci0.rpc_wallet('purchaseticket', [cls.dcr_ticket_account, 0.1, 0]) num_passed += 1 except Exception as e: - logging.warning('coins_loop purchaseticket {}'.format(e)) + if 'double spend' in str(e): + pass + else: + logging.warning('coins_loop purchaseticket {}'.format(e)) try: if num_passed >= 5: @@ -230,8 +234,30 @@ class Test(BaseTest): 'blocks_confirmed': 1, } + def prepare_balance(self, coin, amount: float, port_target_node: int, port_take_from_node: int, test_balance: bool = True) -> None: + delay_iterations = 20 + delay_time = 3 + coin_ticker: str = coin.name + balance_type: str = 'balance' + address_type: str = 'deposit_address' + js_w = read_json_api(port_target_node, 'wallets') + current_balance: float = float(js_w[coin_ticker][balance_type]) + if test_balance and current_balance >= amount: + return + post_json = { + 'value': amount, + 'address': js_w[coin_ticker][address_type], + 'subfee': False, + } + json_rv = read_json_api(port_take_from_node, 'wallets/{}/withdraw'.format(coin_ticker.lower()), post_json) + assert (len(json_rv['txid']) == 64) + wait_for_amount: float = amount + if not test_balance: + wait_for_amount += current_balance + wait_for_balance(test_delay_event, 'http://127.0.0.1:{}/json/wallets/{}'.format(port_target_node, coin_ticker.lower()), balance_type, wait_for_amount, iterations=delay_iterations, delay_time=delay_time) + def test_0001_decred_address(self): - logging.info('---------- Test {}'.format(self.test_coin_from.name)) + logging.info('---------- Test {}'.format(self.test_coin.name)) coin_settings = {'rpcport': 0, 'rpcauth': 'none'} coin_settings.update(REQUIRED_SETTINGS) @@ -249,7 +275,7 @@ class Test(BaseTest): assert (data[2:] == pkh) for i, sc in enumerate(self.swap_clients): - loop_ci = sc.ci(self.test_coin_from) + loop_ci = sc.ci(self.test_coin) root_key = sc.getWalletKey(Coins.DCR, 1) masterpubkey = loop_ci.rpc_wallet('getmasterpubkey') masterpubkey_data = loop_ci.decode_address(masterpubkey)[4:] @@ -261,13 +287,13 @@ class Test(BaseTest): assert (seed_hash == hash160(masterpubkey_data)) def test_001_segwit(self): - logging.info('---------- Test {} segwit'.format(self.test_coin_from.name)) + logging.info('---------- Test {} segwit'.format(self.test_coin.name)) swap_clients = self.swap_clients - ci0 = swap_clients[0].ci(self.test_coin_from) + ci0 = swap_clients[0].ci(self.test_coin) assert (ci0.using_segwit() is True) - addr_out = ci0.rpc_wallet('getnewaddress') + addr_out = ci0.getNewAddress() addr_info = ci0.rpc_wallet('validateaddress', [addr_out,]) assert (addr_info['isvalid'] is True) assert (addr_info['ismine'] is True) @@ -294,13 +320,13 @@ class Test(BaseTest): assert (f_decoded['txid'] == ctx.TxHash().hex()) def test_003_signature_hash(self): - logging.info('---------- Test {} signature_hash'.format(self.test_coin_from.name)) + logging.info('---------- Test {} signature_hash'.format(self.test_coin.name)) # Test that signing a transaction manually produces the same result when signed with the wallet swap_clients = self.swap_clients - ci0 = swap_clients[0].ci(self.test_coin_from) + ci0 = swap_clients[0].ci(self.test_coin) - utxos = ci0.rpc_wallet('listunspent') + utxos = ci0.getNewAddress() addr_out = ci0.rpc_wallet('getnewaddress') rtx = ci0.rpc_wallet('createrawtransaction', [[], {addr_out: 2.0}]) @@ -342,15 +368,18 @@ class Test(BaseTest): assert (len(sent_txid) == 64) def test_004_csv(self): - logging.info('---------- Test {} csv'.format(self.test_coin_from.name)) + logging.info('---------- Test {} csv'.format(self.test_coin.name)) swap_clients = self.swap_clients - ci0 = swap_clients[0].ci(self.test_coin_from) + ci0 = swap_clients[0].ci(self.test_coin) script = bytearray() push_script_data(script, bytes((3,))) script += OP_CHECKSEQUENCEVERIFY.to_bytes(1) script_dest = ci0.getScriptDest(script) + script_info = ci0.rpc_wallet('decodescript', [script_dest.hex(),]) + script_addr = ci0.encodeScriptDest(script_dest) + assert (script_info['addresses'][0] == script_addr) prevout_amount: int = ci0.make_int(1.1) tx = CTransaction() @@ -388,7 +417,7 @@ class Test(BaseTest): push_script_data(signature_script, script) tx_spend.vin[0].signature_script = signature_script - addr_out = ci0.rpc_wallet('getnewaddress') + addr_out = ci0.getNewAddress() pkh = ci0.decode_address(addr_out)[2:] tx_spend.vout.append(ci0.txoType()()) @@ -399,6 +428,7 @@ class Test(BaseTest): try: sent_spend_txid = ci0.rpc_wallet('sendrawtransaction', [tx_spend_hex, ]) + logging.info('Sent tx spending csv output, txid: {}'.format(sent_spend_txid)) except Exception as e: assert ('transaction sequence locks on inputs not met' in str(e)) else: @@ -415,6 +445,135 @@ class Test(BaseTest): assert (sent_spend_txid is not None) + def test_005_watchonly(self): + logging.info('---------- Test {} watchonly'.format(self.test_coin.name)) + + swap_clients = self.swap_clients + ci0 = swap_clients[0].ci(self.test_coin) + ci1 = swap_clients[1].ci(self.test_coin) + + addr = ci0.getNewAddress() + pkh = ci0.decode_address(addr)[2:] + addr_info = ci0.rpc_wallet('validateaddress', [addr,]) + + addr_script = ci0.getPubkeyHashDest(pkh).hex() + script_info = ci0.rpc_wallet('decodescript', [addr_script,]) + assert (addr in script_info['addresses']) + + # Importscript doesn't import an address + ci1.rpc_wallet('importscript', [addr_script,]) + addr_info1 = ci1.rpc_wallet('validateaddress', [addr,]) + assert (addr_info1.get('ismine', False) is False) + + # Would need to run a second wallet daemon? + try: + ro = ci1.rpc_wallet('importpubkey', [addr_info['pubkey'],]) + except Exception as e: + assert ('public keys may only be imported by watching-only wallets' in str(e)) + else: + logging.info('Expected importpubkey to fail on non watching-only wallet') + + chain_height_last = ci1.getChainHeight() + txid = ci0.rpc_wallet('sendtoaddress', [addr, 1]) + + found_txid = None + for i in range(20): + if found_txid is not None: + break + + chain_height_now = ci1.getChainHeight() + while chain_height_last <= chain_height_now: + if found_txid is not None: + break + try: + check_hash = ci1.rpc('getblockhash', [chain_height_last + 1, ]) + except Exception as e: + logging.warning('getblockhash {} failed {}'.format(chain_height_last + 1, e)) + test_delay_event.wait(1) + break + + chain_height_last += 1 + check_hash = ci1.rpc('getblockhash', [chain_height_last, ]) + block_tx = ci1.rpc('getblock', [check_hash, True, True]) + for tx in block_tx['rawtx']: + if found_txid is not None: + break + for txo in tx['vout']: + if addr_script == txo['scriptPubKey']['hex']: + found_txid = tx['txid'] + logging.info('found_txid {}'.format(found_txid)) + break + + test_delay_event.wait(1) + + assert (found_txid is not None) + + def test_008_gettxout(self): + logging.info('---------- Test {} gettxout'.format(self.test_coin.name)) + + ci0 = self.swap_clients[0].ci(self.test_coin) + + addr = ci0.getNewAddress() + + test_amount: float = 1.0 + txid = ci0.withdrawCoin(test_amount, addr) + assert len(txid) == 64 + + unspents = None + for i in range(30): + unspents = ci0.rpc_wallet('listunspent', [0, 999999999, [addr,]]) + if unspents is None: + unspents = [] + if len(unspents) > 0: + break + test_delay_event.wait(1) + assert (len(unspents) == 1) + utxo = unspents[0] + + txout = ci0.rpc('gettxout', [utxo['txid'], utxo['vout'], utxo['tree']]) + + # Lock utxo so it's not spent for tickets, while waiting for depth + rv = ci0.rpc_wallet('lockunspent', [False, [utxo, ]]) + + def wait_for_depth(): + for i in range(20): + logging.info('Waiting for txout depth, iter {}'.format(i)) + txout = ci0.rpc('gettxout', [utxo['txid'], utxo['vout'], utxo['tree']]) + if txout['confirmations'] > 0: + return txout + test_delay_event.wait(1) + raise ValueError('prevout not confirmed') + txout = wait_for_depth() + assert (txout['confirmations'] > 0) + assert (addr in txout['scriptPubKey']['addresses']) + + addr_out = ci0.getNewAddress() + rtx = ci0.rpc_wallet('createrawtransaction', [[utxo, ], {addr_out: test_amount - 0.0001}]) + stx = ci0.rpc_wallet('signrawtransaction', [rtx]) + + chain_height_before_send = ci0.getChainHeight() + sent_txid = ci0.rpc_wallet('sendrawtransaction', [stx['hex'], ]) + + # NOTE: UTXO is still found when spent in the mempool (tested in loop, not delay from wallet to core) + txout = ci0.rpc('gettxout', [utxo['txid'], utxo['vout'], utxo['tree']]) + assert (addr in txout['scriptPubKey']['addresses']) + + for i in range(20): + txout = ci0.rpc('gettxout', [utxo['txid'], utxo['vout'], utxo['tree']]) + if txout is None: + logging.info('txout spent, height before spent {}, height spent {}'.format(chain_height_before_send, ci0.getChainHeight())) + break + test_delay_event.wait(1) + assert (txout is None) + + logging.info('Testing getProofOfFunds') + require_amount: int = ci0.make_int(1) + funds_proof = ci0.getProofOfFunds(require_amount, 'test'.encode('utf-8')) + + logging.info('Testing verifyProofOfFunds') + amount_proved = ci0.verifyProofOfFunds(funds_proof[0], funds_proof[1], funds_proof[2], 'test'.encode('utf-8')) + assert (amount_proved >= require_amount) + if __name__ == '__main__': unittest.main() diff --git a/tests/basicswap/test_btc_xmr.py b/tests/basicswap/test_btc_xmr.py index 9c093e7..ed13666 100644 --- a/tests/basicswap/test_btc_xmr.py +++ b/tests/basicswap/test_btc_xmr.py @@ -13,10 +13,10 @@ from basicswap.db import ( Concepts, ) from basicswap.basicswap import ( - Coins, - SwapTypes, BidStates, + Coins, DebugTypes, + SwapTypes, ) from basicswap.basicswap_util import ( TxLockTypes,