commit e242f50b2ba84a9a8fe631871363272ac3cc2b36 Author: tecnovert Date: Wed Jul 17 17:12:06 2019 +0200 Add to Github diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e480d10 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +old/ +*.pyc +__pycache__ +/dist/ +/*.egg-info +/*.egg diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..6ad19ac --- /dev/null +++ b/.travis.yml @@ -0,0 +1,46 @@ +dist: xenial +os: linux +language: python +python: '3.6' +cache: + directories: + - /opt/binaries +stages: + - lint +env: +global: + - PARTICL_BINDIR=/opt/binaries/particl-0.18.0.12/bin/ + - BITCOIN_BINDIR=/opt/binaries/bitcoin-0.18.0/bin/ + - LITECOIN_BINDIR=/opt/binaries/litecoin-0.17.1/bin/ +before_script: + - if [ ! -d "/opt/binaries" ]; then mkdir -p "/opt/binaries" ; fi + - if [ ! -d "$BITCOIN_BINDIR" ]; then cd "/opt/binaries" && wget https://bitcoincore.org/bin/bitcoin-core-0.18.0/bitcoin-0.18.0-x86_64-linux-gnu.tar.gz && tar xvf bitcoin-0.18.0-x86_64-linux-gnu.tar.gz ; fi + - if [ ! -d "$LITECOIN_BINDIR" ]; then cd "/opt/binaries" && wget https://download.litecoin.org/litecoin-0.17.1/linux/litecoin-0.17.1-x86_64-linux-gnu.tar.gz && tar xvf litecoin-0.17.1-x86_64-linux-gnu.tar.gz ; fi + - if [ ! -d "$PARTICL_BINDIR" ]; then cd "/opt/binaries" && wget https://github.com/particl/particl-core/releases/download/v0.18.0.12/particl-0.18.0.12-x86_64-linux-gnu_nousb.tar.gz && tar xvf particl-0.18.0.12-x86_64-linux-gnu_nousb.tar.gz ; fi + - cd +script: + - cd $TRAVIS_BUILD_DIR + - export PARTICL_BINDIR=/opt/binaries/particl-0.18.0.12/bin/ + - export BITCOIN_BINDIR=/opt/binaries/bitcoin-0.18.0/bin/ + - export LITECOIN_BINDIR=/opt/binaries/litecoin-0.17.1/bin/ + - python setup.py test +after_success: + - echo "End test" +jobs: + include: + - stage: lint + env: + cache: false + language: python + python: '3.6' + before_install: + - sudo apt-get install -y wget gnupg + install: + - travis_retry pip install flake8==3.5.0 + - travis_retry pip install codespell==1.15.0 + before_script: + script: + - PYTHONWARNINGS="ignore" flake8 --ignore=E501,F841 --exclude=key.py,messages_pb2.py + - codespell --check-filenames --disable-colors --quiet-level=7 -S .git + after_success: + - echo "End lint" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0580ffc --- /dev/null +++ b/Dockerfile @@ -0,0 +1,45 @@ +FROM ubuntu:18.10 + +ENV PARTICL_DATADIR="/coindata/particl" \ + PARTICL_BINDIR="/opt/particl" \ + LITECOIN_BINDIR="/opt/litecoin" \ + DATADIRS="/coindata" + +RUN apt-get update; \ + apt-get install -y wget python3-pip curl gnupg unzip protobuf-compiler; + +RUN cd ~; \ + wget https://github.com/particl/coldstakepool/archive/master.zip; \ + unzip master.zip; \ + cd coldstakepool-master; \ + pip3 install .; \ + pip3 install pyzmq plyvel protobuf; + +RUN PARTICL_VERSION=0.18.0.12 PARTICL_VERSION_TAG= PARTICL_ARCH=x86_64-linux-gnu_nousb.tar.gz coldstakepool-prepare --update_core; \ + wget https://download.litecoin.org/litecoin-0.17.1/linux/litecoin-0.17.1-x86_64-linux-gnu.tar.gz; \ + mkdir -p ${LITECOIN_BINDIR}; \ + tar -xvf litecoin-0.17.1-x86_64-linux-gnu.tar.gz -C ${LITECOIN_BINDIR} --strip-components 2 litecoin-0.17.1/bin/litecoind litecoin-0.17.1/bin/litecoin-cli + +# Change to git clone +COPY . /opt/basicswap + +RUN ls /opt/basicswap; \ + cd /opt/basicswap; \ + protoc -I=basicswap --python_out=basicswap basicswap/messages.proto; \ + pip3 install .; + +RUN useradd -ms /bin/bash user; \ + mkdir /coindata && chown user /coindata + +USER user +WORKDIR /home/user + +# Expose html port +EXPOSE 12700 + +ENV LANG C.UTF-8 + +VOLUME /coindata + +ENTRYPOINT ["basicswap-run", "-datadir=/coindata/basicswap"] +CMD diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..50ce5c4 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,20 @@ +The MIT License (MIT) +Copyright (c) 2019 tecnovert + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..f709c24 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,7 @@ +# Include the README +include *.md + +# Include the license file +include LICENSE.txt + +recursive-include doc * diff --git a/README.md b/README.md new file mode 100644 index 0000000..921ca81 --- /dev/null +++ b/README.md @@ -0,0 +1,69 @@ + +# Particl Atomic Swap - Proof of concept + +## Overview + +Simple atomic swap experiment, doesn't have many interesting features yet. +Not ready for real world use. + +Uses Particl secure messaging and Decred style atomic swaps. + +The Particl node is used to hold the keys and sign for the swap transactions. +Other nodes can be run in pruned mode. +A node must be run for each coin type traded. +In the future it should be possible to use data from explorers instead of running a node. + +## Currently a work in progress + +Not ready for real-world use. + +Features still required (of many): + - Cached addresses must be regenerated after use. + - Option to lookup data from public explorers / nodes. + - Load active bids from db at startup + - Ability to swap coin-types without running nodes for all coin-types + - More swap protocols + - Method to load mnemonic into Particl. + + +## Seller first protocol: + +Seller sends the 1st transaction. + +1. Seller posts offer. + - smsg from seller to network + coin-from + coin-to + amount-from + rate + min-amount + time-valid + +2. Buyer posts bid: + - smsg from buyer to seller + offerid + amount + proof-of-funds + address_to_buyer + time-valid + +3. Seller accepts bid: + - verifies proof-of-funds + - generates secret + - submits initiate tx to coin-from network + - smsg from seller to buyer + txid + initiatescript (includes pkhash_to_seller as the pkhash_refund) + +4. Buyer participates: + - inspects initiate tx in coin-from network + - submits participate tx in coin-to network + +5. Seller redeems: + - constructs participatescript + - inspects participate tx in coin-to network + - redeems from participate tx revealing secret + +6. Buyer redeems: + - scans coin-to network for seller-redeem tx + - redeems from initiate tx with revealed secret diff --git a/basicswap/__init__.py b/basicswap/__init__.py new file mode 100644 index 0000000..17b966c --- /dev/null +++ b/basicswap/__init__.py @@ -0,0 +1,3 @@ +name = "basicswap" + +__version__ = "0.0.1" diff --git a/basicswap/basicswap.py b/basicswap/basicswap.py new file mode 100644 index 0000000..ed6e36d --- /dev/null +++ b/basicswap/basicswap.py @@ -0,0 +1,2151 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2019 tecnovert +# Distributed under the MIT software license, see the accompanying +# file LICENSE.txt or http://www.opensource.org/licenses/mit-license.php. + +import os +import re +import time +import datetime as dt +import zmq +import threading +import traceback +import struct +import hashlib +import plyvel +import subprocess +import logging +import sqlalchemy as sa +from sqlalchemy.orm import sessionmaker, scoped_session +from sqlalchemy.ext.declarative import declarative_base +from enum import IntEnum, auto +from . import __version__ +from .util import ( + COIN, + callrpc, + pubkeyToAddress, + format8, + encodeAddress, + decodeAddress, + SerialiseNum, + DeserialiseNum, + decodeWif, + toWIF, + getKeyID, +) + +from .chainparams import ( + chainparams, + Coins, +) + +from .messages_pb2 import ( + OfferMessage, + BidMessage, + BidAcceptMessage, +) + +import basicswap.config as cfg +import basicswap.segwit_addr as segwit_addr + + +DEBUG = True +SMSG_SECONDS_IN_DAY = 86400 + +CURRENT_DB_VERSION = 1 + +DBT_DATA = ord('d') + + +MIN_OFFER_VALID_TIME = 60 * 10 +MAX_OFFER_VALID_TIME = 60 * 60 * 48 +MIN_BID_VALID_TIME = 60 * 10 +MAX_BID_VALID_TIME = 60 * 60 * 48 + + +class MessageTypes(IntEnum): + OFFER = auto() + BID = auto() + BID_ACCEPT = auto() + + +class SwapTypes(IntEnum): + SELLER_FIRST = auto() + BUYER_FIRST = auto() + + +class OfferStates(IntEnum): + OFFER_SENT = auto() + OFFER_RECEIVED = auto() + OFFER_ABANDONED = auto() + + +class BidStates(IntEnum): + BID_SENT = auto() + BID_RECEIVED = auto() + BID_ACCEPTED = auto() # BidAcceptMessage received/sent + SWAP_INITIATED = auto() # Initiate txn validated + SWAP_PARTICIPATING = auto() # Participate txn validated + SWAP_COMPLETED = auto() # All swap txns spent + SWAP_TIMEDOUT = auto() + BID_ABANDONED = auto() # Bid will no longer be processed + + +class TxStates(IntEnum): + TX_NONE = auto() + TX_SENT = auto() + TX_CONFIRMED = auto() + TX_REDEEMED = auto() + TX_REFUNDED = auto() + + +class OpCodes(IntEnum): + OP_0 = 0x00, + OP_PUSHDATA1 = 0x4c, + OP_1 = 0x51, + OP_IF = 0x63, + OP_ELSE = 0x67, + OP_ENDIF = 0x68, + OP_DROP = 0x75, + OP_DUP = 0x76, + OP_SIZE = 0x82, + OP_EQUAL = 0x87, + OP_EQUALVERIFY = 0x88, + OP_SHA256 = 0xa8, + OP_HASH160 = 0xa9, + OP_CHECKSIG = 0xac, + OP_CHECKLOCKTIMEVERIFY = 0xb1, + OP_CHECKSEQUENCEVERIFY = 0xb2, + + +SEQUENCE_LOCK_BLOCKS = 1 +SEQUENCE_LOCK_TIME = 2 +SEQUENCE_LOCKTIME_GRANULARITY = 9 # 512 seconds +SEQUENCE_LOCKTIME_TYPE_FLAG = (1 << 22) +SEQUENCE_LOCKTIME_MASK = 0x0000ffff +INITIATE_TX_TIMEOUT = 10 * 60 + + +def getOfferState(state): + if state == OfferStates.OFFER_SENT: + return 'Sent' + if state == OfferStates.OFFER_RECEIVED: + return 'Received' + if state == OfferStates.OFFER_ABANDONED: + return 'Abandoned' + return 'Unknown' + + +def getBidState(state): + if state == BidStates.BID_SENT: + return 'Sent' + if state == BidStates.BID_RECEIVED: + return 'Received' + if state == BidStates.BID_ACCEPTED: + return 'Accepted' + if state == BidStates.SWAP_INITIATED: + return 'Initiated' + if state == BidStates.SWAP_PARTICIPATING: + return 'Participating' + if state == BidStates.SWAP_COMPLETED: + return 'Completed' + if state == BidStates.SWAP_TIMEDOUT: + return 'Timed-out' + if state == BidStates.BID_ABANDONED: + return 'Abandoned' + return 'Unknown' + + +def getTxState(state): + if state == TxStates.TX_NONE: + return 'None' + if state == TxStates.TX_SENT: + return 'Sent' + if state == TxStates.TX_CONFIRMED: + return 'Confirmed' + if state == TxStates.TX_REDEEMED: + return 'Redeemed' + if state == TxStates.TX_REFUNDED: + return 'Refunded' + return 'Unknown' + + +def getLockName(lock_type): + if lock_type == SEQUENCE_LOCK_BLOCKS: + return 'Sequence lock, blocks' + if lock_type == SEQUENCE_LOCK_TIME: + return 'Sequence lock, time' + + +def getExpectedSequence(lockType, lockVal, coin_type): + assert(lockVal >= 1) + if lockType == SEQUENCE_LOCK_BLOCKS: + return lockVal + if lockType == 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 decodeSequence(lock_value): + # Return the raw value + if lock_value & SEQUENCE_LOCKTIME_TYPE_FLAG: + return (lock_value & SEQUENCE_LOCKTIME_MASK) << SEQUENCE_LOCKTIME_GRANULARITY + return lock_value & SEQUENCE_LOCKTIME_MASK + + +def buildContractScriptCSV(sequence, secret_hash, pkh_redeem, pkh_refund): + script = bytearray([ + OpCodes.OP_IF, + OpCodes.OP_SIZE, + 0x01, 0x20, # 32 + OpCodes.OP_EQUALVERIFY, + OpCodes.OP_SHA256, + 0x20]) \ + + secret_hash \ + + bytearray([ + OpCodes.OP_EQUALVERIFY, + OpCodes.OP_DUP, + OpCodes.OP_HASH160, + 0x14]) \ + + pkh_redeem \ + + bytearray([OpCodes.OP_ELSE, ]) \ + + SerialiseNum(sequence) \ + + bytearray([ + OpCodes.OP_CHECKSEQUENCEVERIFY, + OpCodes.OP_DROP, + OpCodes.OP_DUP, + OpCodes.OP_HASH160, + 0x14]) \ + + pkh_refund \ + + bytearray([ + OpCodes.OP_ENDIF, + OpCodes.OP_EQUALVERIFY, + OpCodes.OP_CHECKSIG]) + return script + + +def extractScriptSecretHash(script): + return script[7:39] + + +def getVoutByAddress(txjs, p2sh): + for o in txjs['vout']: + try: + if p2sh in o['scriptPubKey']['addresses']: + return o['n'] + except Exception: + pass + raise ValueError('Address output not found in txn') + + +def getVoutByP2WSH(txjs, p2wsh_hex): + for o in txjs['vout']: + try: + if p2wsh_hex == o['scriptPubKey']['hex']: + return o['n'] + except Exception: + pass + raise ValueError('P2WSH output not found in txn') + + +def getP2SHScriptForHash(p2sh): + return bytearray([OpCodes.OP_HASH160, 0x14]) \ + + p2sh \ + + bytearray([OpCodes.OP_EQUAL]) + + +def getP2WSH(script): + return bytearray([OpCodes.OP_0, 0x20]) + hashlib.sha256(script).digest() + + +def replaceAddrPrefix(addr, coin_type, chain_name, addr_type='pubkey_address'): + return encodeAddress(bytes((chainparams[coin_type][chain_name][addr_type],)) + decodeAddress(addr)[1:]) + + +Base = declarative_base() + + +class Offer(Base): + __tablename__ = 'offers' + + offer_id = sa.Column(sa.LargeBinary, primary_key=True) + + coin_from = sa.Column(sa.Integer) + coin_to = sa.Column(sa.Integer) + amount_from = sa.Column(sa.BigInteger) + rate = sa.Column(sa.BigInteger) + min_bid_amount = sa.Column(sa.BigInteger) + time_valid = sa.Column(sa.BigInteger) + lock_type = sa.Column(sa.Integer) + lock_value = sa.Column(sa.Integer) + swap_type = sa.Column(sa.Integer) + + proof_address = sa.Column(sa.String) + proof_signature = sa.Column(sa.LargeBinary) + pkhash_seller = sa.Column(sa.LargeBinary) + secret_hash = sa.Column(sa.LargeBinary) + + addr_from = sa.Column(sa.String) + created_at = sa.Column(sa.BigInteger) + expire_at = sa.Column(sa.BigInteger) + was_sent = sa.Column(sa.Boolean) + + auto_accept_bids = sa.Column(sa.Boolean) + + state = sa.Column(sa.Integer) + states = sa.Column(sa.LargeBinary) # Packed states and times + + def setState(self, new_state): + now = int(time.time()) + self.state = new_state + if self.states is None: + self.states = struct.pack('i', self.db_version)) + else: + self.db_version = struct.unpack('>i', n)[0] + + n = db.get(bytes([DBT_DATA]) + b'contract_count') + self._contract_count = 0 if n is None else struct.unpack('>i', n)[0] + db.close() + + self.zmqContext = zmq.Context() + self.zmqSubscriber = self.zmqContext.socket(zmq.SUB) + + self.zmqSubscriber.connect(self.settings['zmqhost'] + ':' + str(self.settings['zmqport'])) + self.zmqSubscriber.setsockopt_string(zmq.SUBSCRIBE, 'smsg') + + # Defaults + self.coin_clients = {} + self.coin_clients[Coins.PART] = self.setDefaultConnectParams(Coins.PART) + self.coin_clients[Coins.BTC] = self.setDefaultConnectParams(Coins.BTC) + self.coin_clients[Coins.LTC] = self.setDefaultConnectParams(Coins.LTC) + + if self.chain == 'regtest': + SMSG_SECONDS_IN_DAY = 600 + + self.swaps_in_progress = dict() + + self.check_progress_seconds = self.settings.get('check_progress_seconds', 60) + self.check_watched_seconds = self.settings.get('check_watched_seconds', 60) + self.check_expired_seconds = self.settings.get('check_expired_seconds', 60 * 5) + + self.last_checked_progress = 0 + self.last_checked_watched = 0 + self.last_checked_expired = 0 + + self.mxDB = threading.RLock() + + self.bidcount = 0 + + def prepareLogging(self): + self.log = logging.getLogger(self.log_name) + self.log.propagate = False + + formatter = logging.Formatter('%(asctime)s %(levelname)s : %(message)s') + stream_stdout = logging.StreamHandler() + if self.log_name != 'BasicSwap': + stream_stdout.setFormatter(logging.Formatter('%(asctime)s %(name)s %(levelname)s : %(message)s')) + else: + stream_stdout.setFormatter(formatter) + stream_fp = logging.StreamHandler(self.fp) + stream_fp.setFormatter(formatter) + + self.log.setLevel(logging.DEBUG if self.debug else logging.INFO) + self.log.addHandler(stream_fp) + self.log.addHandler(stream_stdout) + + def getChainClientSettings(self, coin): + try: + return self.settings['chainclients'][chainparams[coin]['name']] + except Exception: + return {} + + def setDefaultConnectParams(self, coin): + chain_client_settings = self.getChainClientSettings(coin) + + datadir = chain_client_settings.get('datadir', os.path.join(cfg.DATADIRS, chainparams[coin]['name'])) + blocks_confirmed = chain_client_settings.get('blocks_confirmed', 6) + connection_type = chain_client_settings.get('connection_type', 'none') + use_segwit = chain_client_settings.get('use_segwit', False) + + rpcauth = None + if connection_type == 'rpc': + if 'rpcauth' in chain_client_settings: + rpcauth = chain_client_settings['rpcauth'] + elif 'rpcpassword' in chain_client_settings: + rpcauth = chain_client_settings['rpcuser'] + ':' + chain_client_settings['rpcpassword'] + if rpcauth is None: + # Wait for daemon to start + authcookiepath = os.path.join(datadir, '' if self.chain == 'mainnet' else self.chain, '.cookie') + for i in range(10): + if not os.path.exists(authcookiepath): + time.sleep(0.5) + try: + with open(authcookiepath) as fp: + rpcauth = fp.read() + except Exception: + self.log.warning('Unable to read authcookie for %s', str(coin)) + + # TODO: Load from db + last_height_checked = 0 + + return { + 'coin': coin, + 'connection_type': connection_type, + 'datadir': datadir, + 'rpcport': chain_client_settings.get('rpcport', chainparams[coin][self.chain]['rpcport']), + 'rpcauth': rpcauth, + 'blocks_confirmed': blocks_confirmed, + 'watched_outputs': [], + 'last_height_checked': last_height_checked, + 'use_segwit': use_segwit, + } + + def start(self): + self.log.info('Starting BasicSwap %s\n\n', __version__) + + self.upgradeDatabase(self.db_version) + self.waitForDaemonRPC() + core_version = self.callcoinrpc(Coins.PART, 'getnetworkinfo')['version'] + self.log.info('Particl Core version %d', core_version) + + self.log.info('sqlalchemy version %s', sa.__version__) + + self.initialise() + + def stopRunning(self, with_code=0): + self.fail_code = with_code + self.is_running = False + + def upgradeDatabase(self, db_version): + if db_version >= CURRENT_DB_VERSION: + return + + self.log.info('Upgrading leveldb Database from version %d to %d.', db_version, CURRENT_DB_VERSION) + + db = plyvel.DB(self.db_path, create_if_missing=True) + db.put(bytes([DBT_DATA]) + b'db_version', struct.pack('>i', CURRENT_DB_VERSION)) + db.close() + + def waitForDaemonRPC(self): + for i in range(21): + if not self.is_running: + return + if i == 20: + self.log.error('Can\'t connect to daemon RPC, exiting.') + self.stopRunning(1) # systemd will try restart if fail_code != 0 + return + try: + self.callrpc('getwalletinfo', [], self.wallet) + break + except Exception as ex: + traceback.print_exc() + self.log.warning('Can\'t connect to daemon RPC, trying again in %d second/s.', (1 + i)) + time.sleep(1 + i) + + def loadFromDB(self): + self.log.info('Loading data from db') + self.mxDB.acquire() + try: + self.log.debug('loadFromDB TODO') + session = scoped_session(self.session_factory) + for bid in session.query(Bid): + if bid.state and bid.state > BidStates.BID_RECEIVED and bid.state < BidStates.SWAP_COMPLETED: + self.log.debug('Loading active bid %s', bid.bid_id.hex()) + finally: + session.close() + session.remove() + self.mxDB.release() + + def initialise(self): + self.log.debug('network_key %s\nnetwork_pubkey %s\nnetwork_addr %s', + self.network_key, self.network_pubkey, self.network_addr) + + ro = self.callrpc('smsglocalkeys') + found = False + for k in ro['smsg_keys']: + if k['address'] == self.network_addr: + found = True + break + if not found: + self.log.info('Importing network key to SMSG') + self.callrpc('smsgimportprivkey', [self.network_key, 'basicswap offers']) + ro = self.callrpc('smsglocalkeys', ['anon', '-', self.network_addr]) + assert(ro['result'] == 'Success.') + + # TODO: Ensure smsg is enabled for the active wallet. + + self.loadFromDB() + + # Scan inbox + options = {'encoding': 'hex'} + ro = self.callrpc('smsginbox', ['unread', '', options]) + nm = 0 + for msg in ro['messages']: + self.processMsg(msg) + nm += 1 + self.log.info('Scanned %d unread messages.', nm) + + def validateOfferAmounts(self, coin_from, coin_to, amount, rate, min_bid_amount): + assert(amount >= min_bid_amount), 'amount < min_bid_amount' + assert(amount > chainparams[coin_from][self.chain]['min_amount']), 'From amount below min value for chain' + assert(amount < chainparams[coin_from][self.chain]['max_amount']), 'From amount above max value for chain' + + amount_to = (amount * rate) // COIN + assert(amount_to > chainparams[coin_to][self.chain]['min_amount']), 'To amount below min value for chain' + assert(amount_to < chainparams[coin_to][self.chain]['max_amount']), 'To amount above max value for chain' + + def postOffer(self, coin_from, coin_to, amount, rate, min_bid_amount, swap_type, + lock_type=SEQUENCE_LOCK_TIME, lock_value=48 * 60 * 60, auto_accept_bids=False): + + # Offer to send offer.amount_from of coin_from in exchange for offer.amount_from * offer.rate of coin_to + + assert(coin_from != coin_to), 'coin_from == coin_to' + try: + coin_from_t = Coins(coin_from) + except Exception: + raise ValueError('Unknown coin from type') + try: + coin_to_t = Coins(coin_to) + except Exception: + raise ValueError('Unknown coin to type') + + self.validateOfferAmounts(coin_from_t, coin_to_t, amount, rate, min_bid_amount) + + self.mxDB.acquire() + try: + msg_buf = OfferMessage() + msg_buf.coin_from = int(coin_from) + msg_buf.coin_to = int(coin_to) + msg_buf.amount_from = amount + msg_buf.rate = int(rate) + msg_buf.min_bid_amount = min_bid_amount + + msg_buf.time_valid = 60 * 60 + msg_buf.lock_type = lock_type + msg_buf.lock_value = lock_value + msg_buf.swap_type = swap_type + + offer_bytes = msg_buf.SerializeToString() + payload_hex = str.format('{:02x}', MessageTypes.OFFER) + offer_bytes.hex() + + # TODO: reuse address? + offer_addr = self.callrpc('getnewaddress') + self.callrpc('smsgaddlocaladdress', [offer_addr]) # Enable receiving smsg + ro = self.callrpc('smsgsend', [offer_addr, self.network_addr, payload_hex, False, 1, False, False, True]) + msg_id = ro['msgid'] + + offer_id = bytes.fromhex(msg_id) + + session = scoped_session(self.session_factory) + + offer = Offer( + offer_id=offer_id, + + coin_from=msg_buf.coin_from, + coin_to=msg_buf.coin_to, + amount_from=msg_buf.amount_from, + rate=msg_buf.rate, + min_bid_amount=msg_buf.min_bid_amount, + time_valid=msg_buf.time_valid, + lock_type=int(msg_buf.lock_type), + lock_value=msg_buf.lock_value, + swap_type=msg_buf.swap_type, + + addr_from=offer_addr, + created_at=int(time.time()), + expire_at=int(time.time()) + msg_buf.time_valid, + was_sent=True, + auto_accept_bids=auto_accept_bids,) + offer.setState(OfferStates.OFFER_SENT) + session.add(offer) + + session.add(SentOffer(offer_id=offer_id)) + session.commit() + session.close() + session.remove() + finally: + self.mxDB.release() + self.log.info('Sent OFFER %s', offer_id.hex()) + return offer_id + + def getContractPubkey(self, date, contract_count): + account = self.callcoinrpc(Coins.PART, 'extkey', ['account']) + + # Derive an address to use for a contract + evkey = self.callcoinrpc(Coins.PART, 'extkey', ['account', 'default', 'true'])['evkey'] + + # Should the coin path be included? + path = '44445555h' + path += '/' + str(date.year) + '/' + str(date.month) + '/' + str(date.day) + path += '/' + str(contract_count) + + extkey = self.callcoinrpc(Coins.PART, 'extkey', ['info', evkey, path])['key_info']['result'] + pubkey = self.callcoinrpc(Coins.PART, 'extkey', ['info', extkey])['key_info']['pubkey'] + return bytes.fromhex(pubkey) + + def getContractPrivkey(self, date, contract_count): + # Derive an address to use for a contract + evkey = self.callcoinrpc(Coins.PART, 'extkey', ['account', 'default', 'true'])['evkey'] + + path = '44445555h' + path += '/' + str(date.year) + '/' + str(date.month) + '/' + str(date.day) + path += '/' + str(contract_count) + + extkey = self.callcoinrpc(Coins.PART, 'extkey', ['info', evkey, path])['key_info']['result'] + privkey = self.callcoinrpc(Coins.PART, 'extkey', ['info', extkey])['key_info']['privkey'] + raw = decodeAddress(privkey)[1:] + if len(raw) > 32: + raw = raw[:32] + return raw + + def getContractSecret(self, date, contract_count): + # Derive a key to use for a contract secret + evkey = self.callcoinrpc(Coins.PART, 'extkey', ['account', 'default', 'true'])['evkey'] + + path = '44445555h/99999' + path += '/' + str(date.year) + '/' + str(date.month) + '/' + str(date.day) + path += '/' + str(contract_count) + + return hashlib.sha256(bytes(self.callcoinrpc(Coins.PART, 'extkey', ['info', evkey, path])['key_info']['result'], 'utf-8')).digest() + + def getReceiveAddressForCoin(self, coin_type): + if coin_type == Coins.PART: + new_addr = self.callcoinrpc(Coins.PART, 'getnewaddress') + elif coin_type == Coins.LTC or coin_type == Coins.BTC: + args = [] + if self.coin_clients[coin_type]['use_segwit']: + args = ['swap_receive', 'bech32'] + new_addr = self.callcoinrpc(coin_type, 'getnewaddress', args) + else: + raise ValueError('Unknown coin type.') + self.log.debug('Generated new receive address %s for %s', new_addr, str(coin_type)) + return new_addr + + def getFeeRateForCoin(self, coin_type): + # TODO: Per coin settings to override feerate + try: + return self.callcoinrpc(coin_type, 'estimatesmartfee', [1])['feerate'] + except Exception: + try: + fee_rate = self.callcoinrpc(coin_type, 'getwalletinfo')['paytxfee'] + assert(fee_rate > 0.0) + return fee_rate + except Exception: + return self.callcoinrpc(coin_type, 'getnetworkinfo')['relayfee'] + + def getTicker(self, coin_type): + ticker = chainparams[coin_type]['ticker'] + if self.chain == 'testnet': + ticker = 't' + ticker + if self.chain == 'regtest': + ticker = 'rt' + ticker + return ticker + + def withdrawCoin(self, coin_type, value, addr_to): + self.log.info('withdrawCoin %s %s to %s', value, self.getTicker(coin_type), addr_to) + return self.callcoinrpc(coin_type, 'sendtoaddress', [addr_to, value]) + + def cacheNewAddressForCoin(self, coin_type): + self.log.debug('cacheNewAddressForCoin %s', coin_type) + db = plyvel.DB(self.db_path, create_if_missing=True) + dbkey = bytes([DBT_DATA]) + bytes('receive_addr_' + chainparams[coin_type]['name'], 'utf-8') + addr = self.getReceiveAddressForCoin(coin_type) + db.put(dbkey, bytes(addr, 'utf-8')) + db.close() + return addr + + def getCachedAddressForCoin(self, coin_type): + self.log.debug('getCachedAddressForCoin %s', coin_type) + # TODO: auto refresh after used + db = plyvel.DB(self.db_path, create_if_missing=True) + dbkey = bytes([DBT_DATA]) + bytes('receive_addr_' + chainparams[coin_type]['name'], 'utf-8') + dbval = db.get(dbkey) + if dbval is None: + addr = self.getReceiveAddressForCoin(coin_type) + db.put(dbkey, bytes(addr, 'utf-8')) + else: + addr = dbval.decode('utf-8') + db.close() + return addr + + def getNewContractId(self): + self._contract_count += 1 + db = plyvel.DB(self.db_path, create_if_missing=True) + db.put(bytes([DBT_DATA]) + b'contract_count', struct.pack('>i', self._contract_count)) + db.close() + return self._contract_count + + def getProofOfFunds(self, coin_type, amount_for): + self.log.debug('getProofOfFunds %s %s', str(coin_type), format8(amount_for)) + + if self.coin_clients[coin_type]['connection_type'] != 'rpc': + return (None, None) + + # TODO: Lock unspent and use same output/s to fund bid + unspent_addr = dict() + unspent = self.callcoinrpc(coin_type, 'listunspent') + for u in unspent: + unspent_addr[u['address']] = unspent_addr.get(u['address'], 0.0) * COIN + u['amount'] * COIN + + sign_for_addr = None + for addr, value in unspent_addr.items(): + if value >= amount_for: + sign_for_addr = addr + break + + assert(sign_for_addr is not None), 'Could not find address with enough funds for proof' + + self.log.debug('sign_for_addr %s', sign_for_addr) + if self.coin_clients[coin_type]['use_segwit']: + # 'Address does not refer to key' for non p2pkh + addrinfo = self.callcoinrpc(coin_type, 'getaddressinfo', [sign_for_addr]) + pkh = addrinfo['scriptPubKey'][4:] + sign_for_addr = encodeAddress(bytes((chainparams[coin_type][self.chain]['pubkey_address'],)) + bytes.fromhex(pkh)) + self.log.debug('sign_for_addr converted %s', sign_for_addr) + signature = self.callcoinrpc(coin_type, 'signmessage', [sign_for_addr, sign_for_addr + '_swap_proof']) + + return (sign_for_addr, signature) + + def saveBid(self, bid_id, bid): + self.mxDB.acquire() + try: + session = scoped_session(self.session_factory) + session.add(bid) + session.commit() + session.close() + session.remove() + finally: + self.mxDB.release() + + def postBid(self, offer_id, amount): + self.log.debug('postBid %s %s', offer_id.hex(), format8(amount)) + + # Bid to send bid.amount * offer.rate of coin_to in exchange for bid.amount of coin_from + + offer = self.getOffer(offer_id) + assert(offer) + + # TODO: Assert offer still valid + + msg_buf = BidMessage() + msg_buf.offer_msg_id = offer_id + msg_buf.time_valid = 60 * 10 + msg_buf.amount = amount # amount of coin_from + + coin_from = Coins(offer.coin_from) + coin_to = Coins(offer.coin_to) + + contract_count = self.getNewContractId() + + now = int(time.time()) + if offer.swap_type == SwapTypes.SELLER_FIRST: + msg_buf.pkhash_buyer = getKeyID(self.getContractPubkey(dt.datetime.fromtimestamp(now).date(), contract_count)) + + proof_addr, proof_sig = self.getProofOfFunds(coin_to, msg_buf.amount) + msg_buf.proof_address = proof_addr + msg_buf.proof_signature = proof_sig + + bid_bytes = msg_buf.SerializeToString() + payload_hex = str.format('{:02x}', MessageTypes.BID) + bid_bytes.hex() + + # TODO: reuse address? + bid_addr = self.callrpc('getnewaddress') + self.callrpc('smsgaddlocaladdress', [bid_addr]) # Enable receiving smsg + ro = self.callrpc('smsgsend', [bid_addr, offer.addr_from, payload_hex, False, 1, False, False, True]) + msg_id = ro['msgid'] + + bid_id = bytes.fromhex(msg_id) + bid = Bid( + bid_id=bid_id, + offer_id=offer_id, + amount=msg_buf.amount, + pkhash_buyer=msg_buf.pkhash_buyer, + proof_address=msg_buf.proof_address, + + created_at=now, + contract_count=contract_count, + amount_to=(msg_buf.amount * offer.rate) // COIN, + expire_at=now + msg_buf.time_valid, + bid_addr=bid_addr, + was_sent=True, + ) + bid.setState(BidStates.BID_SENT) + + self.saveBid(bid_id, bid) + + self.log.info('Sent BID %s', bid_id.hex()) + return bid_id + + def getOffer(self, offer_id, sent=False): + self.mxDB.acquire() + try: + session = scoped_session(self.session_factory) + return session.query(Offer).filter_by(offer_id=offer_id).first() + finally: + session.close() + session.remove() + self.mxDB.release() + + def getBid(self, bid_id): + self.mxDB.acquire() + try: + session = scoped_session(self.session_factory) + return session.query(Bid).filter_by(bid_id=bid_id).first() + finally: + session.close() + session.remove() + self.mxDB.release() + + def getBidAndOffer(self, bid_id): + self.mxDB.acquire() + try: + session = scoped_session(self.session_factory) + bid = session.query(Bid).filter_by(bid_id=bid_id).first() + return bid, session.query(Offer).filter_by(offer_id=bid.offer_id).first() + finally: + session.close() + session.remove() + self.mxDB.release() + + def acceptBid(self, bid_id): + self.log.info('Accepting bid %s', bid_id.hex()) + + bid, offer = self.getBidAndOffer(bid_id) + assert(bid), 'Bid not found' + assert(offer), 'Offer not found' + + # Ensure bid is still valid + now = int(time.time()) + assert(bid.expire_at > now), 'Bid expired' + assert(bid.state == BidStates.BID_RECEIVED), 'Wrong bid state: {}'.format(BidStates(bid.state)) + + if bid.contract_count is None: + bid.contract_count = self.getNewContractId() + + coin_from = Coins(offer.coin_from) + bid_date = dt.datetime.fromtimestamp(bid.created_at).date() + + # TODO: Use CLTV for coins without CSV + sequence = getExpectedSequence(offer.lock_type, offer.lock_value, coin_from) + secret = self.getContractSecret(bid_date, bid.contract_count) + secret_hash = hashlib.sha256(secret).digest() + + pubkey_refund = self.getContractPubkey(bid_date, bid.contract_count) + pkhash_refund = getKeyID(pubkey_refund) + + script = buildContractScriptCSV(sequence, secret_hash, bid.pkhash_buyer, pkhash_refund) + + p2sh = self.callcoinrpc(Coins.PART, 'decodescript', [script.hex()])['p2sh'] + + bid.initiate_script = script + bid.pkhash_seller = pkhash_refund + + txn = self.createInitiateTxn(coin_from, bid_id, bid) + + # Store the signed refund txn in case wallet is locked when refund is possible + refund_txn = self.createRefundTxn(coin_from, txn, bid, script) + bid.initiate_txn_refund = bytes.fromhex(refund_txn) + + txid = self.submitTxn(coin_from, txn) + self.log.debug('Submitted initiate txn %s to %s chain for bid %s', txid, chainparams[coin_from]['name'], bid_id.hex()) + bid.setITXState(TxStates.TX_SENT) + + # Check non-bip68 final + try: + txid = self.submitTxn(coin_from, bid.initiate_txn_refund.hex()) + self.log.error('Submit refund_txn unexpectedly worked: ' + txid) + except Exception as e: + if 'non-BIP68-final' not in str(e): + self.log.error('Submit refund_txn unexpected error' + str(e)) + + if txid is not None: + msg_buf = BidAcceptMessage() + msg_buf.bid_msg_id = bid_id + msg_buf.initiate_txid = bytes.fromhex(txid) + msg_buf.contract_script = bytes(script) + + bid_bytes = msg_buf.SerializeToString() + payload_hex = str.format('{:02x}', MessageTypes.BID_ACCEPT) + bid_bytes.hex() + ro = self.callrpc('smsgsend', [offer.addr_from, bid.bid_addr, payload_hex, False, 1, False, False, True]) + msg_id = ro['msgid'] + + accept_msg_id = bytes.fromhex(msg_id) + + bid.accept_msg_id = accept_msg_id + bid.initiate_txid = bytes.fromhex(txid) + bid.setState(BidStates.BID_ACCEPTED) + + self.log.info('Sent BID_ACCEPT %s', accept_msg_id.hex()) + + self.saveBid(bid_id, bid) + self.swaps_in_progress[bid_id] = (bid, offer) + + def abandonOffer(self, offer_id): + self.log.info('Abandoning Offer %s', offer_id.hex()) + self.mxDB.acquire() + try: + session = scoped_session(self.session_factory) + offer = session.query(Offer).filter_by(offer_id=offer_id).first() + assert(offer), 'Offer not found' + + # TODO: abandon linked bids? + + # Mark bid as abandoned, no further processing will be done + offer.setState(OfferStates.OFFER_ABANDONED) + session.commit() + finally: + session.close() + session.remove() + self.mxDB.release() + + def abandonBid(self, bid_id): + self.log.info('Abandoning Bid %s', bid_id.hex()) + self.mxDB.acquire() + try: + session = scoped_session(self.session_factory) + bid = session.query(Bid).filter_by(bid_id=bid_id).first() + assert(bid), 'Bid not found' + offer = session.query(Offer).filter_by(offer_id=bid.offer_id).first() + assert(offer), 'Offer not found' + + # Mark bid as abandoned, no further processing will be done + bid.setState(BidStates.BID_ABANDONED) + session.commit() + + # Remove from in progress + self.swaps_in_progress.pop(bid_id, None) + + # Remove any watched outputs + self.removeWatchedOutput(Coins(offer.coin_from), bid_id, None) + self.removeWatchedOutput(Coins(offer.coin_to), bid_id, None) + finally: + session.close() + session.remove() + self.mxDB.release() + + def encodeSegwitP2WSH(self, coin_type, p2wsh): + return segwit_addr.encode(chainparams[coin_type][self.chain]['hrp'], 0, p2wsh[2:]) + + def encodeSegwit(self, coin_type, raw): + return segwit_addr.encode(chainparams[coin_type][self.chain]['hrp'], 0, raw) + + def decodeSegwit(self, coin_type, addr): + return bytes(segwit_addr.decode(chainparams[coin_type][self.chain]['hrp'], addr)[1]) + + def getScriptAddress(self, coin_type, script): + return pubkeyToAddress(chainparams[coin_type][self.chain]['script_address'], script) + + def createInitiateTxn(self, coin_type, bid_id, bid): + if self.coin_clients[coin_type]['connection_type'] != 'rpc': + return None + + if self.coin_clients[coin_type]['use_segwit']: + addr_to = self.encodeSegwitP2WSH(coin_type, getP2WSH(bid.initiate_script)) + else: + addr_to = self.getScriptAddress(coin_type, bid.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 = self.callcoinrpc(coin_type, 'createrawtransaction', [[], {addr_to: format8(bid.amount)}]) + txn_funded = self.callcoinrpc(coin_type, 'fundrawtransaction', [txn, {'lockUnspents': True}])['hex'] + txn_signed = self.callcoinrpc(coin_type, 'signrawtransactionwithwallet', [txn_funded])['hex'] + return txn_signed + + def deriveParticipateScript(self, bid_id, bid, offer): + self.log.debug('deriveParticipateScript for bid %s', bid_id.hex()) + + coin_to = Coins(offer.coin_to) + + bid_date = dt.datetime.fromtimestamp(bid.created_at).date() + + # Participate txn is locked for half the time of the initiate txn + lock_value = decodeSequence(offer.lock_value) // 2 + sequence = getExpectedSequence(offer.lock_type, lock_value, coin_to) + + secret_hash = extractScriptSecretHash(bid.initiate_script) + pkhash_seller = bid.pkhash_seller + pkhash_buyer_refund = bid.pkhash_buyer + bid.participate_script = buildContractScriptCSV(sequence, secret_hash, pkhash_seller, pkhash_buyer_refund) + + def createParticipateTxn(self, bid_id, bid, offer): + self.log.debug('createParticipateTxn') + + offer_id = bid.offer_id + coin_to = Coins(offer.coin_to) + + if self.coin_clients[coin_to]['connection_type'] != 'rpc': + return None + + amount_to = bid.amount_to + # Check required? + assert(amount_to == (bid.amount * offer.rate) // COIN) + + if self.coin_clients[coin_to]['use_segwit']: + p2wsh = getP2WSH(bid.participate_script) + addr_to = self.encodeSegwitP2WSH(coin_to, p2wsh) + else: + addr_to = self.getScriptAddress(coin_to, bid.participate_script) + + txn = self.callcoinrpc(coin_to, 'createrawtransaction', [[], {addr_to: format8(amount_to)}]) + txn_funded = self.callcoinrpc(coin_to, 'fundrawtransaction', [txn, {'lockUnspents': True}])['hex'] + + txn_signed = self.callcoinrpc(coin_to, 'signrawtransactionwithwallet', [txn_funded])['hex'] + + refund_txn = self.createRefundTxn(coin_to, txn_signed, bid, bid.participate_script) + bid.participate_txn_refund = bytes.fromhex(refund_txn) + + chain_height = self.callcoinrpc(coin_to, 'getblockchaininfo')['blocks'] + txjs = self.callcoinrpc(coin_to, 'decoderawtransaction', [txn_signed]) + txid = txjs['txid'] + + if self.coin_clients[coin_to]['use_segwit']: + vout = getVoutByP2WSH(txjs, p2wsh.hex()) + else: + vout = getVoutByAddress(txjs, addr_to) + self.addParticipateTxn(bid_id, bid, coin_to, txid, vout, chain_height) + + return txn_signed + + def getContractSpendTxVSize(self, coin_type, redeem=True): + tx_vsize = 5 # Add a few bytes, sequence in script takes variable amount of bytes + if coin_type == Coins.PART: + tx_vsize += 204 if redeem else 187 + if self.coin_clients[coin_type]['use_segwit']: + tx_vsize += 143 if redeem else 134 + else: + tx_vsize += 323 if redeem else 287 + return tx_vsize + + def createRedeemTxn(self, coin_type, bid, for_txn_type='participate', addr_redeem_out=None, fee_rate=None): + self.log.debug('createRedeemTxn for coin %s', str(coin_type)) + + if for_txn_type == 'participate': + prev_txnid = bid.participate_txid.hex() + prev_n = bid.participate_txn_n + txn_script = bid.participate_script + prev_amount = bid.amount_to + else: + prev_txnid = bid.initiate_txid.hex() + prev_n = bid.initiate_txn_n + txn_script = bid.initiate_script + prev_amount = bid.amount + + if self.coin_clients[coin_type]['use_segwit']: + prev_p2wsh = getP2WSH(txn_script) + script_pub_key = prev_p2wsh.hex() + else: + script_pub_key = getP2SHScriptForHash(getKeyID(txn_script)).hex() + + prevout = { + 'txid': prev_txnid, + 'vout': prev_n, + 'scriptPubKey': script_pub_key, + 'redeemScript': txn_script.hex(), + 'amount': format8(prev_amount)} + + bid_date = dt.datetime.fromtimestamp(bid.created_at).date() + wif_prefix = chainparams[Coins.PART][self.chain]['key_prefix'] + pubkey = self.getContractPubkey(bid_date, bid.contract_count) + privkey = toWIF(wif_prefix, self.getContractPrivkey(bid_date, bid.contract_count)) + + secret = bid.recovered_secret + if secret is None: + secret = self.getContractSecret(bid_date, bid.contract_count) + assert(len(secret) == 32), 'Bad secret length' + + if self.coin_clients[coin_type]['connection_type'] != 'rpc': + return None + + prevout_s = ' in={}:{}'.format(prev_txnid, prev_n) + + if fee_rate is None: + fee_rate = self.getFeeRateForCoin(coin_type) + + tx_vsize = self.getContractSpendTxVSize(coin_type) + tx_fee = (fee_rate * tx_vsize) / 1000 + + self.log.debug('Redeem tx fee %s, rate %s', format8(tx_fee * COIN), str(fee_rate)) + + amount_out = prev_amount - tx_fee * COIN + assert(amount_out > 0) + + if addr_redeem_out is None: + addr_redeem_out = self.getReceiveAddressForCoin(coin_type) + assert(addr_redeem_out is not None) + + if self.coin_clients[coin_type]['use_segwit']: + # Change to btc hrp + addr_redeem_out = self.encodeSegwit(Coins.PART, self.decodeSegwit(coin_type, addr_redeem_out)) + else: + addr_redeem_out = replaceAddrPrefix(addr_redeem_out, Coins.PART, self.chain) + self.log.debug('addr_redeem_out %s', addr_redeem_out) + output_to = ' outaddr={}:{}'.format(format8(amount_out), addr_redeem_out) + if coin_type == Coins.PART: + redeem_txn = self.calltx('-create' + prevout_s + output_to) + else: + redeem_txn = self.calltx('-btcmode -create nversion=2' + prevout_s + output_to) + + options = {} + if self.coin_clients[coin_type]['use_segwit']: + options['force_segwit'] = True + redeem_sig = self.callcoinrpc(Coins.PART, 'createsignaturewithkey', [redeem_txn, prevout, privkey, 'ALL', options]) + if coin_type == Coins.PART or self.coin_clients[coin_type]['use_segwit']: + witness_stack = [ + redeem_sig, + pubkey.hex(), + secret.hex(), + '01', + txn_script.hex()] + redeem_txn = self.calltx(redeem_txn + ' witness=0:' + ':'.join(witness_stack)) + else: + script = format(len(redeem_sig) // 2, '02x') + redeem_sig + script += format(33, '02x') + pubkey.hex() + script += format(32, '02x') + secret.hex() + script += format(OpCodes.OP_1, '02x') + script += format(OpCodes.OP_PUSHDATA1, '02x') + format(len(txn_script), '02x') + txn_script.hex() + redeem_txn = self.calltx(redeem_txn + ' scriptsig=0:' + script) + + ro = self.callcoinrpc(Coins.PART, 'verifyrawtransaction', [redeem_txn, [prevout]]) + assert(ro['inputs_valid'] is True), 'inputs_valid is false' + assert(ro['complete'] is True), 'complete is false' + assert(ro['validscripts'] == 1), 'validscripts != 1' + + if self.debug: + # Check fee + if self.coin_clients[coin_type]['connection_type'] == 'rpc': + redeem_txjs = self.callcoinrpc(coin_type, 'decoderawtransaction', [redeem_txn]) + self.log.debug('vsize paid, actual vsize %d %d', tx_vsize, redeem_txjs['vsize']) + assert(tx_vsize >= redeem_txjs['vsize']) + + redeem_txjs = self.callcoinrpc(Coins.PART, 'decoderawtransaction', [redeem_txn]) + self.log.debug('Have valid redeem txn %s for contract %s tx %s', redeem_txjs['txid'], for_txn_type, prev_txnid) + + return redeem_txn + + def createRefundTxn(self, coin_type, txn, bid, txn_script, addr_refund_out=None, fee_rate=None): + self.log.debug('createRefundTxn') + if self.coin_clients[coin_type]['connection_type'] != 'rpc': + return None + + txjs = self.callcoinrpc(Coins.PART, 'decoderawtransaction', [txn]) + if self.coin_clients[coin_type]['use_segwit']: + p2wsh = getP2WSH(txn_script) + vout = getVoutByP2WSH(txjs, p2wsh.hex()) + else: + addr_to = self.getScriptAddress(Coins.PART, txn_script) + vout = getVoutByAddress(txjs, addr_to) + + bid_date = dt.datetime.fromtimestamp(bid.created_at).date() + wif_prefix = chainparams[Coins.PART][self.chain]['key_prefix'] + pubkey = self.getContractPubkey(bid_date, bid.contract_count) + privkey = toWIF(wif_prefix, self.getContractPrivkey(bid_date, bid.contract_count)) + + prev_amount = txjs['vout'][vout]['value'] + prevout = { + 'txid': txjs['txid'], + 'vout': vout, + 'scriptPubKey': txjs['vout'][vout]['scriptPubKey']['hex'], + 'redeemScript': txn_script.hex(), + 'amount': prev_amount} + + sequence = DeserialiseNum(txn_script, 64) + prevout_s = ' in={}:{}:{}'.format(txjs['txid'], vout, sequence) + + if fee_rate is None: + fee_rate = self.getFeeRateForCoin(coin_type) + + tx_vsize = self.getContractSpendTxVSize(coin_type, False) + tx_fee = (fee_rate * tx_vsize) / 1000 + + self.log.debug('Refund tx fee %s, rate %s', format8(tx_fee * COIN), str(fee_rate)) + + amount_out = prev_amount * COIN - tx_fee * COIN + assert(amount_out > 0) + + if addr_refund_out is None: + addr_refund_out = self.getReceiveAddressForCoin(coin_type) + assert(addr_refund_out is not None) + if self.coin_clients[coin_type]['use_segwit']: + # Change to btc hrp + addr_refund_out = self.encodeSegwit(Coins.PART, self.decodeSegwit(coin_type, addr_refund_out)) + else: + addr_refund_out = replaceAddrPrefix(addr_refund_out, Coins.PART, self.chain) + self.log.debug('addr_refund_out %s', addr_refund_out) + + output_to = ' outaddr={}:{}'.format(format8(amount_out), addr_refund_out) + if coin_type == Coins.PART: + refund_txn = self.calltx('-create' + prevout_s + output_to) + else: + refund_txn = self.calltx('-btcmode -create nversion=2' + prevout_s + output_to) + + options = {} + if self.coin_clients[coin_type]['use_segwit']: + options['force_segwit'] = True + refund_sig = self.callcoinrpc(Coins.PART, 'createsignaturewithkey', [refund_txn, prevout, privkey, 'ALL', options]) + if coin_type == Coins.PART or self.coin_clients[coin_type]['use_segwit']: + witness_stack = [ + refund_sig, + pubkey.hex(), + '', # SCRIPT_VERIFY_MINIMALIF + txn_script.hex()] + refund_txn = self.calltx(refund_txn + ' witness=0:' + ':'.join(witness_stack)) + else: + script = format(len(refund_sig) // 2, '02x') + refund_sig + script += format(33, '02x') + pubkey.hex() + script += format(OpCodes.OP_0, '02x') + script += format(OpCodes.OP_PUSHDATA1, '02x') + format(len(txn_script), '02x') + txn_script.hex() + refund_txn = self.calltx(refund_txn + ' scriptsig=0:' + script) + + ro = self.callcoinrpc(Coins.PART, 'verifyrawtransaction', [refund_txn, [prevout]]) + assert(ro['inputs_valid'] is True), 'inputs_valid is false' + assert(ro['complete'] is True), 'complete is false' + assert(ro['validscripts'] == 1), 'validscripts != 1' + + if self.debug: + # Check fee + if self.coin_clients[coin_type]['connection_type'] == 'rpc': + refund_txjs = self.callcoinrpc(coin_type, 'decoderawtransaction', [refund_txn]) + self.log.debug('vsize paid, actual vsize %d %d', tx_vsize, refund_txjs['vsize']) + assert(tx_vsize >= refund_txjs['vsize']) + + refund_txjs = self.callcoinrpc(Coins.PART, 'decoderawtransaction', [refund_txn]) + self.log.debug('Have valid refund txn %s for contract tx %s', refund_txjs['txid'], txjs['txid']) + + return refund_txn + + def submitTxn(self, coin_type, txn): + # self.log.debug('submitTxn %s', str(coin_type)) + if txn is None: + return None + if self.coin_clients[coin_type]['connection_type'] != 'rpc': + return None + return self.callcoinrpc(coin_type, 'sendrawtransaction', [txn]) + + def initiateTxnConfirmed(self, bid_id, bid, offer): + self.log.debug('initiateTxnConfirmed for bid %s', bid_id.hex()) + bid.setState(BidStates.SWAP_INITIATED) + bid.setITXState(TxStates.TX_CONFIRMED) + + # Seller first mode, buyer participates + self.deriveParticipateScript(bid_id, bid, offer) + if bid.was_sent: + self.log.debug('Preparing participate txn for bid %s', bid_id.hex()) + + coin_to = Coins(offer.coin_to) + txn = self.createParticipateTxn(bid_id, bid, offer) + txid = self.submitTxn(coin_to, txn) + self.log.debug('Submitted participate txn %s to %s chain for bid %s', txid, chainparams[coin_to]['name'], bid_id.hex()) + bid.setPTXState(TxStates.TX_SENT) + + self.saveBid(bid_id, bid) + + def addParticipateTxn(self, bid_id, bid, coin_type, txid_hex, vout, tx_height): + bid.participate_txid = bytes.fromhex(txid_hex) + bid.participate_txn_n = vout + # Start checking for spends of participate_txn before fully confirmed + + chain_name = chainparams[coin_type]['name'] + self.log.debug('Watching %s chain for spend of output %s %d', chain_name, txid_hex, vout) + + # TODO: Check connection type + if len(self.coin_clients[coin_type]['watched_outputs']) == 0: + self.coin_clients[coin_type]['last_height_checked'] = tx_height + self.log.debug('Start checking %s chain at height %d', chain_name, tx_height) + + if self.coin_clients[coin_type]['last_height_checked'] > tx_height: + self.coin_clients[coin_type]['last_height_checked'] = tx_height + self.log.debug('Rewind checking of %s chain to height %d', chain_name, tx_height) + + self.log.debug('Adding watched output %s bid %s tx %s type %s', coin_type, bid_id.hex(), txid_hex, BidStates.SWAP_PARTICIPATING) + self.coin_clients[coin_type]['watched_outputs'].append((bid_id, txid_hex, vout, BidStates.SWAP_PARTICIPATING)) + + def participateTxnConfirmed(self, bid_id, bid, offer): + self.log.debug('participateTxnConfirmed for bid %s', bid_id.hex()) + bid.setState(BidStates.SWAP_PARTICIPATING) + bid.setPTXState(TxStates.TX_CONFIRMED) + + # Seller redeems from participate txn + if bid.was_received: + coin_to = Coins(offer.coin_to) + txn = self.createRedeemTxn(coin_to, bid) + txid = self.submitTxn(coin_to, txn) + self.log.debug('Submitted participate redeem txn %s to %s chain for bid %s', txid, chainparams[coin_to]['name'], bid_id.hex()) + # TX_REDEEMED will be set when spend is detected + # TODO: Wait for depth? + + self.saveBid(bid_id, bid) + + def lookupChainHeight(self, coin_type): + return self.callcoinrpc(coin_type, 'getblockchaininfo')['blocks'] + + def lookupUnspentByAddress(self, coin_type, address, sum_output=False, assert_amount=None, assert_txid=None): + num_blocks = self.callcoinrpc(coin_type, 'getblockchaininfo')['blocks'] + + sum_unspent = 0 + self.log.debug('[rm] scantxoutset start') # scantxoutset is slow + ro = self.callcoinrpc(coin_type, 'scantxoutset', ['start', ['addr({})'.format(address)]]) + self.log.debug('[rm] scantxoutset end') + for o in ro['unspents']: + if assert_txid and o['txid'] != assert_txid: + continue + # Verify amount + if assert_amount: + assert(o['amount'] * COIN == assert_amount), 'Incorrect output amount in txn {}.'.format(assert_txid) + + if not sum_output: + if o['height'] > 0: + n_conf = num_blocks - o['height'] + else: + n_conf = -1 + return { + 'txid': o['txid'], + 'index': o['vout'], + 'height': o['height'], + 'n_conf': n_conf, + } + else: + sum_unspent += o['amount'] * COIN + if sum_output: + return sum_unspent + return None + + def checkBidState(self, bid_id, bid, offer): + # assert(self.mxDB.locked()) + # Return True to remove bid from in-progress list + + state = BidStates(bid.state) + self.log.debug('checkBidState %s %s', bid_id.hex(), str(state)) + + coin_from = Coins(offer.coin_from) + coin_to = Coins(offer.coin_to) + # TODO: Batch calls to scantxoutset + # TODO: timeouts + if state == BidStates.BID_ACCEPTED: + # Waiting for initiate txn to be confirmed in 'from' chain + initiate_txnid_hex = bid.initiate_txid.hex() + p2sh = self.getScriptAddress(coin_from, bid.initiate_script) + index = None + if coin_from == Coins.PART: # Has txindex + try: + initiate_txn = self.callcoinrpc(coin_from, 'getrawtransaction', [initiate_txnid_hex, True]) + # Verify amount + vout = getVoutByAddress(initiate_txn, p2sh) + assert(initiate_txn['vout'][vout]['value'] * COIN == bid.amount), 'Incorrect output amount in initiate txn.' + + bid.initiate_txn_conf = initiate_txn['confirmations'] + index = vout + except Exception: + pass + else: + if self.coin_clients[coin_from]['use_segwit']: + addr = self.encodeSegwitP2WSH(coin_from, getP2WSH(bid.initiate_script)) + else: + addr = p2sh + found = self.lookupUnspentByAddress(coin_from, addr, assert_amount=bid.amount, assert_txid=initiate_txnid_hex) + if found: + bid.initiate_txn_conf = found['n_conf'] + index = found['index'] + + if bid.initiate_txn_conf is not None: + self.log.debug('initiate_txnid %s confirms %d', initiate_txnid_hex, bid.initiate_txn_conf) + + if bid.initiate_txn_n is None: + bid.initiate_txn_n = index + # Start checking for spends of initiate_txn before fully confirmed + self.log.debug('Adding watched output %s bid %s tx %s type %s', coin_from, bid_id.hex(), initiate_txnid_hex, BidStates.SWAP_INITIATED) + self.coin_clients[coin_from]['watched_outputs'].append((bid_id, initiate_txnid_hex, bid.initiate_txn_n, BidStates.SWAP_INITIATED)) + if bid.initiate_txn_state is None or bid.initiate_txn_state < TxStates.TX_SENT: + bid.setITXState(TxStates.TX_SENT) + + if bid.initiate_txn_conf >= self.coin_clients[coin_from]['blocks_confirmed']: + self.initiateTxnConfirmed(bid_id, bid, offer) + + # Bid times out if buyer doesn't see tx in chain within + if bid.state_time + INITIATE_TX_TIMEOUT < int(time.time()): + self.log.info('Swap timed out waiting for initiate tx for bid %s', bid_id.hex()) + bid.setState(BidStates.SWAP_TIMEDOUT) + self.saveBid(bid_id, bid) + return True # Mark bid for archiving + elif state == BidStates.SWAP_INITIATED: + # Waiting for participate txn to be confirmed in 'to' chain + if self.coin_clients[coin_to]['use_segwit']: + addr = self.encodeSegwitP2WSH(coin_to, getP2WSH(bid.participate_script)) + else: + addr = self.getScriptAddress(coin_to, bid.participate_script) + + found = self.lookupUnspentByAddress(coin_to, addr, assert_amount=bid.amount_to) + if found: + bid.participate_txn_conf = found['n_conf'] + index = found['index'] + if bid.participate_txid is None: + self.log.debug('Found bid %s participate txn %s in chain %s', bid_id.hex(), found['txid'], coin_to) + if found['height'] > 1: + tx_height = found['height'] + else: + tx_height = self.lookupChainHeight(coin_to) + self.addParticipateTxn(bid_id, bid, coin_to, found['txid'], found['index'], tx_height) + bid.setPTXState(TxStates.TX_SENT) + self.saveBid(bid_id, bid) + + if bid.participate_txn_conf is not None: + self.log.debug('participate_txid %s confirms %d', bid.participate_txid.hex(), bid.participate_txn_conf) + if bid.participate_txn_conf >= self.coin_clients[coin_to]['blocks_confirmed']: + self.participateTxnConfirmed(bid_id, bid, offer) + elif state == BidStates.SWAP_PARTICIPATING: + # Waiting for initiate txn spend + pass + else: + self.log.warning('checkBidState unknown state %s', state) + + if state > BidStates.BID_ACCEPTED: + # Wait for spend of all known swap txns + if (bid.initiate_txn_state is None or bid.initiate_txn_state >= TxStates.TX_REDEEMED) \ + and (bid.participate_txn_state is None or bid.participate_txn_state >= TxStates.TX_REDEEMED): + self.log.info('Swap completed for bid %s', bid_id.hex()) + bid.setState(BidStates.SWAP_COMPLETED) + self.saveBid(bid_id, bid) + return True # Mark bid for archiving + + # Try refund, keep trying until sent tx is spent + if (bid.initiate_txn_state == TxStates.TX_SENT or bid.initiate_txn_state == TxStates.TX_CONFIRMED) \ + and bid.initiate_txn_refund is not None: + try: + txid = self.submitTxn(coin_from, bid.initiate_txn_refund.hex()) + self.log.debug('Submitted initiate refund txn %s to %s chain for bid %s', txid, chainparams[coin_from]['name'], bid_id.hex()) + # State will update when spend is detected + except Exception as e: + if 'non-BIP68-final (code 64)' not in str(e): + self.log.warning('Error trying to submit initiate refund txn: %s', str(e)) + if (bid.participate_txn_state == TxStates.TX_SENT or bid.participate_txn_state == TxStates.TX_CONFIRMED) \ + and bid.participate_txn_refund is not None: + try: + txid = self.submitTxn(coin_to, bid.participate_txn_refund.hex()) + self.log.debug('Submitted participate refund txn %s to %s chain for bid %s', txid, chainparams[coin_to]['name'], bid_id.hex()) + # State will update when spend is detected + except Exception as e: + if 'non-BIP68-final (code 64)' not in str(e): + self.log.warning('Error trying to submit participate refund txn: %s', str(e)) + return False # Bid is still active + + def extractSecret(self, coin_type, bid, spend_in): + try: + if coin_type == Coins.PART or self.coin_clients[coin_type]['use_segwit']: + assert(len(spend_in['txinwitness']) == 5) + return bytes.fromhex(spend_in['txinwitness'][2]) + else: + script_sig = spend_in['scriptSig']['asm'].split(' ') + assert(len(script_sig) == 5) + return bytes.fromhex(script_sig[2]) + except Exception: + return None + + def removeWatchedOutput(self, coin_type, bid_id, txid_hex): + # Remove all for bid if txid is None + self.log.debug('removeWatchedOutput %s %s %s', str(coin_type), bid_id.hex(), txid_hex) + old_len = len(self.coin_clients[coin_type]['watched_outputs']) + for i in range(old_len - 1, -1, -1): + wo = self.coin_clients[coin_type]['watched_outputs'][i] + if wo[0] == bid_id and (txid_hex is None or wo[1] == txid_hex): + del self.coin_clients[coin_type]['watched_outputs'][i] + self.log.debug('Removed watched output %s %s %s', str(coin_type), bid_id.hex(), wo[1]) + + def initiateTxnSpent(self, bid_id, spend_txid, spend_n, spend_txn): + self.log.debug('Bid %s initiate txn spent by %s %d', bid_id.hex(), spend_txid, spend_n) + + if bid_id in self.swaps_in_progress: + bid = self.swaps_in_progress[bid_id][0] + offer = self.swaps_in_progress[bid_id][1] + + bid.initiate_spend_txid = bytes.fromhex(spend_txid) + bid.initiate_spend_n = spend_n + spend_in = spend_txn['vin'][spend_n] + + coin_from = Coins(offer.coin_from) + coin_to = Coins(offer.coin_to) + + secret = self.extractSecret(coin_from, bid, spend_in) + if secret is None: + self.log.info('Bid %s initiate txn refunded by %s %d', bid_id.hex(), spend_txid, spend_n) + # TODO: Wait for depth? + bid.setITXState(TxStates.TX_REFUNDED) + else: + self.log.info('Bid %s initiate txn redeemed by %s %d', bid_id.hex(), spend_txid, spend_n) + # TODO: Wait for depth? + bid.setITXState(TxStates.TX_REDEEMED) + + self.removeWatchedOutput(coin_from, bid_id, bid.initiate_txid.hex()) + self.saveBid(bid_id, bid) + + def participateTxnSpent(self, bid_id, spend_txid, spend_n, spend_txn): + self.log.debug('Bid %s participate txn spent by %s %d', bid_id.hex(), spend_txid, spend_n) + + # TODO: More SwapTypes + if bid_id in self.swaps_in_progress: + bid = self.swaps_in_progress[bid_id][0] + offer = self.swaps_in_progress[bid_id][1] + + bid.participate_spend_txid = bytes.fromhex(spend_txid) + bid.participate_spend_n = spend_n + spend_in = spend_txn['vin'][spend_n] + + coin_from = Coins(offer.coin_from) + coin_to = Coins(offer.coin_to) + + secret = self.extractSecret(coin_to, bid, spend_in) + if secret is None: + self.log.info('Bid %s participate txn refunded by %s %d', bid_id.hex(), spend_txid, spend_n) + # TODO: Wait for depth? + bid.setPTXState(TxStates.TX_REFUNDED) + else: + self.log.debug('Secret %s extracted from participate spend %s %d', secret.hex(), spend_txid, spend_n) + bid.recovered_secret = secret + # TODO: Wait for depth? + bid.setPTXState(TxStates.TX_REDEEMED) + + if bid.was_sent: + txn = self.createRedeemTxn(coin_from, bid, for_txn_type='initiate') + txid = self.submitTxn(coin_from, txn) + + bid.initiate_spend_txid = bytes.fromhex(txid) + # bid.initiate_txn_redeem = bytes.fromhex(txn) # Worth keeping? + self.log.debug('Submitted initiate redeem txn %s to %s chain for bid %s', txid, chainparams[coin_from]['name'], bid_id.hex()) + + # TODO: Wait for depth? new state SWAP_TXI_REDEEM_SENT? + + self.removeWatchedOutput(coin_to, bid_id, bid.participate_txid.hex()) + self.saveBid(bid_id, bid) + + def checkForSpends(self, coin_type, c): + # assert(self.mxDB.locked()) self.log.debug('checkForSpends %s', coin_type) + + if coin_type == Coins.PART: + # TODO: batch getspentinfo + for o in c['watched_outputs']: + found_spend = None + try: + found_spend = self.callcoinrpc(Coins.PART, 'getspentinfo', [{'txid': o[1], 'index': o[2]}]) + except Exception as e: + if 'Unable to get spent info' not in str(e): + self.log.warning('getspentinfo %s', str(e)) + if found_spend is not None: + self.log.debug('Found spend in spentindex %s %d in %s %d', o[1], o[2], found_spend['txid'], found_spend['index']) + bid_id = o[0] + spend_txid = found_spend['txid'] + spend_n = found_spend['index'] + spend_txn = self.callcoinrpc(Coins.PART, 'getrawtransaction', [spend_txid, True]) + if o[3] == BidStates.SWAP_PARTICIPATING: + self.participateTxnSpent(bid_id, spend_txid, spend_n, spend_txn) + else: + self.initiateTxnSpent(bid_id, spend_txid, spend_n, spend_txn) + else: + chain_blocks = self.callcoinrpc(coin_type, 'getblockchaininfo')['blocks'] + last_height_checked = c['last_height_checked'] + self.log.debug('chain_blocks, last_height_checked %s %s', chain_blocks, last_height_checked) + while last_height_checked < chain_blocks: + block_hash = self.callcoinrpc(coin_type, 'getblockhash', [last_height_checked + 1]) + block = self.callcoinrpc(coin_type, 'getblock', [block_hash, 2]) + + for tx in block['tx']: + for i, inp in enumerate(tx['vin']): + for o in c['watched_outputs']: + inp_txid = inp.get('txid', None) + if inp_txid is None: # Coinbase + continue + if inp_txid == o[1] and inp['vout'] == o[2]: + self.log.debug('Found spend from search %s %d in %s %d', o[1], o[2], tx['txid'], i) + bid_id = o[0] + if o[3] == BidStates.SWAP_PARTICIPATING: + self.participateTxnSpent(bid_id, tx['txid'], i, tx) + else: + self.initiateTxnSpent(bid_id, tx['txid'], i, tx) + last_height_checked += 1 + if c['last_height_checked'] != last_height_checked: + c['last_height_checked'] = last_height_checked + # TODO: save to db + + def expireMessages(self): + self.mxDB.acquire() + try: + now = int(time.time()) + options = {'encoding': 'none'} + ro = self.callrpc('smsginbox', ['all', '', options]) + for msg in ro['messages']: + expire_at = msg['sent'] + msg['daysretention'] * SMSG_SECONDS_IN_DAY + if expire_at < now: + options = {'encoding': 'none', 'delete': True} + del_msg = self.callrpc('smsg', [msg['msgid'], options]) + + # TODO: remove offers from db + + self.last_checked_expired = now + finally: + self.mxDB.release() + + def processOffer(self, msg): + assert(msg['to'] == self.network_addr), 'Offer received on wrong address' + + offer_bytes = bytes.fromhex(msg['hex'][2:-2]) + offer_data = OfferMessage() + offer_data.ParseFromString(offer_bytes) + + # Validate data + now = int(time.time()) + coin_from = Coins(offer_data.coin_from) + coin_to = Coins(offer_data.coin_to) + chain_from = chainparams[coin_from][self.chain] + assert(offer_data.coin_from != offer_data.coin_to), 'coin_from == coin_to' + + self.validateOfferAmounts(coin_from, coin_to, offer_data.amount_from, offer_data.rate, offer_data.min_bid_amount) + + assert(offer_data.time_valid >= MIN_OFFER_VALID_TIME and offer_data.time_valid <= MAX_OFFER_VALID_TIME), 'Invalid time_valid' + assert(msg['sent'] + offer_data.time_valid >= now), 'Offer expired' + + assert(offer_data.lock_type == OfferMessage.SEQUENCE_LOCK_TIME or + offer_data.lock_type == OfferMessage.SEQUENCE_LOCK_BLOCKS), 'Unknown locktype' + # TODO: lock value valid range + + if offer_data.swap_type == SwapTypes.SELLER_FIRST: + assert(len(offer_data.proof_address) == 0) + assert(len(offer_data.proof_signature) == 0) + assert(len(offer_data.pkhash_seller) == 0) + assert(len(offer_data.secret_hash) == 0) + elif offer_data.swap_type == SwapTypes.BUYER_FIRST: + raise ValueError('TODO') + else: + raise ValueError('Unknown swap type {}.'.format(offer_data.swap_type)) + + offer_id = bytes.fromhex(msg['msgid']) + + session = scoped_session(self.session_factory) + # Check for sent + existing_offer = self.getOffer(offer_id) + if existing_offer is None: + offer = Offer( + offer_id=offer_id, + + coin_from=offer_data.coin_from, + coin_to=offer_data.coin_to, + amount_from=offer_data.amount_from, + rate=offer_data.rate, + min_bid_amount=offer_data.min_bid_amount, + time_valid=offer_data.time_valid, + lock_type=int(offer_data.lock_type), + lock_value=offer_data.lock_value, + swap_type=offer_data.swap_type, + + addr_from=msg['from'], + created_at=msg['sent'], + expire_at=msg['sent'] + offer_data.time_valid, + was_sent=False) + offer.setState(OfferStates.OFFER_RECEIVED) + session.add(offer) + self.log.debug('Received new offer %s', offer_id.hex()) + else: + existing_offer.setState(OfferStates.OFFER_RECEIVED) + session.add(existing_offer) + session.commit() + session.close() + session.remove() + + def processBid(self, msg): + self.log.debug('Processing bid msg %s', msg['msgid']) + now = int(time.time()) + bid_bytes = bytes.fromhex(msg['hex'][2:-2]) + bid_data = BidMessage() + bid_data.ParseFromString(bid_bytes) + + # Validate data + assert(len(bid_data.offer_msg_id) == 28) + assert(bid_data.time_valid >= MIN_BID_VALID_TIME and bid_data.time_valid <= MAX_BID_VALID_TIME), 'Invalid time_valid' + + offer_id = bid_data.offer_msg_id + offer = self.getOffer(offer_id, sent=True) + assert(offer and offer.was_sent), 'Unknown offerid' + + assert(offer.state == OfferStates.OFFER_RECEIVED), 'Bad offer state' + + assert(msg['to'] == offer.addr_from), 'Received on incorrect address' + assert(now <= offer.expire_at), 'Offer expired' + assert(bid_data.amount >= offer.min_bid_amount), 'Bid amount below minimum' + assert(now <= msg['sent'] + bid_data.time_valid), 'Bid expired' + + # TODO: allow higher bids + # assert(bid_data.rate != offer['data'].rate), 'Bid rate mismatch' + + coin_to = Coins(offer.coin_to) + swap_type = offer.swap_type + if swap_type == SwapTypes.SELLER_FIRST: + assert(len(bid_data.pkhash_buyer) == 20), 'Bad pkhash_buyer length' + + # Verify proof of funds + bid_proof_address = replaceAddrPrefix(bid_data.proof_address, Coins.PART, self.chain) + mm = chainparams[coin_to]['message_magic'] + passed = self.callcoinrpc(Coins.PART, 'verifymessage', [bid_proof_address, bid_data.proof_signature, bid_data.proof_address + '_swap_proof', mm]) + assert(passed is True), 'Proof of funds signature invalid' + + if self.coin_clients[coin_to]['use_segwit']: + addr_search = self.encodeSegwit(coin_to, decodeAddress(bid_data.proof_address)[1:]) + else: + addr_search = bid_data.proof_address + + sum_unspent = self.lookupUnspentByAddress(coin_to, addr_search, sum_output=True) + self.log.debug('Proof of funds %s %s', bid_data.proof_address, format8(sum_unspent)) + assert(sum_unspent >= bid_data.amount), 'Proof of funds failed' + + elif swap_type == SwapTypes.BUYER_FIRST: + raise ValueError('TODO') + else: + raise ValueError('Unknown swap type {}.'.format(swap_type)) + + bid_id = bytes.fromhex(msg['msgid']) + + bid = self.getBid(bid_id) + if bid is None: + bid = Bid( + bid_id=bid_id, + offer_id=offer_id, + amount=bid_data.amount, + pkhash_buyer=bid_data.pkhash_buyer, + + created_at=msg['sent'], + amount_to=(bid_data.amount * offer.rate) // COIN, + expire_at=msg['sent'] + bid_data.time_valid, + bid_addr=msg['from'], + was_received=True, + ) + else: + bid.created_at = msg['sent'] + bid.expire_at = msg['sent'] + bid_data.time_valid + bid.was_received = True + if len(bid_data.proof_address) > 0: + bid.proof_address = bid_data.proof_address + + bid.setState(BidStates.BID_RECEIVED) + + self.log.info('Received valid bid %s for offer %s', bid_id.hex(), bid_data.offer_msg_id.hex()) + self.saveBid(bid_id, bid) + + # Auto accept bid if set and no other non-abandoned bid for this order exists + if offer.auto_accept_bids: + if self.countAcceptedBids(offer_id) > 0: + self.log.info('Not auto accepting bid %s, already have', bid_id.hex()) + else: + self.log.info('Auto accepting bid %s', bid_id.hex()) + self.acceptBid(bid_id) + + def processBidAccept(self, msg): + self.log.debug('Processing bid accepted msg %s', msg['msgid']) + now = int(time.time()) + bid_accept_bytes = bytes.fromhex(msg['hex'][2:-2]) + bid_accept_data = BidAcceptMessage() + bid_accept_data.ParseFromString(bid_accept_bytes) + + assert(len(bid_accept_data.bid_msg_id) == 28), 'Bad bid_msg_id length' + assert(len(bid_accept_data.initiate_txid) == 32), 'Bad initiate_txid length' + assert(len(bid_accept_data.contract_script) < 100), 'Bad contract_script length' + + self.log.debug('for bid %s', bid_accept_data.bid_msg_id.hex()) + + decoded_script = self.callcoinrpc(Coins.PART, 'decodescript', [bid_accept_data.contract_script.hex()]) + + # TODO: Verify script without decoding? + prog = re.compile('OP_IF OP_SIZE 32 OP_EQUALVERIFY OP_SHA256 (\w+) OP_EQUALVERIFY OP_DUP OP_HASH160 (\w+) OP_ELSE (\d+) OP_CHECKSEQUENCEVERIFY OP_DROP OP_DUP OP_HASH160 (\w+) OP_ENDIF OP_EQUALVERIFY OP_CHECKSIG') + rr = prog.match(decoded_script['asm']) + if not rr: + raise ValueError('Bad script') + scriptvalues = rr.groups() + + bid_id = bid_accept_data.bid_msg_id + bid, offer = self.getBidAndOffer(bid_id) + assert(bid is not None and bid.was_sent is True), 'Unknown bidid' + assert(offer), 'Offer not found ' + bid.offer_id.hex() + + # assert(bid.expire_at > now), 'Bid expired' # How much time over to accept + + if bid.state >= BidStates.BID_ACCEPTED: + if bid.was_received: # Sent to self + self.log.info('Received valid bid accept %s for bid %s sent to self', bid.accept_msg_id.hex(), bid_id.hex()) + return + raise ValueError('Wrong bid state: {}'.format(str(BidStates(bid.state)))) + + coin_from = Coins(offer.coin_from) + expect_sequence = getExpectedSequence(offer.lock_type, offer.lock_value, coin_from) + + assert(len(scriptvalues[0]) == 64), 'Bad secret_hash length' + assert(bytes.fromhex(scriptvalues[1]) == bid.pkhash_buyer), 'pkhash_buyer mismatch' + assert(int(scriptvalues[2]) == expect_sequence), 'sequence mismatch' + assert(len(scriptvalues[3]) == 40), 'pkhash_refund bad length' + + assert(bid.accept_msg_id is None), 'Bid already accepted' + + bid.accept_msg_id = bytes.fromhex(msg['msgid']) + bid.initiate_txid = bid_accept_data.initiate_txid + bid.initiate_script = bid_accept_data.contract_script + bid.pkhash_seller = bytes.fromhex(scriptvalues[3]) + bid.setState(BidStates.BID_ACCEPTED) + bid.setITXState(TxStates.TX_NONE) + + self.log.info('Received valid bid accept %s for bid %s', bid.accept_msg_id.hex(), bid_id.hex()) + + self.saveBid(bid_id, bid) + self.swaps_in_progress[bid_id] = (bid, offer) + + def processMsg(self, msg): + self.mxDB.acquire() + try: + msg_type = int(msg['hex'][:2], 16) + + rv = None + if msg_type == MessageTypes.OFFER: + self.processOffer(msg) + elif msg_type == MessageTypes.BID: + self.processBid(msg) + elif msg_type == MessageTypes.BID_ACCEPT: + self.processBidAccept(msg) + + except Exception as ex: + self.log.error('processMsg %s', str(ex)) + traceback.print_exc() + finally: + self.mxDB.release() + + def processZmqSmsg(self): + message = self.zmqSubscriber.recv() + clear = self.zmqSubscriber.recv() + + if message[0] == 3: # Paid smsg + return # TODO: switch to paid? + + msg_id = message[2:] + options = {'encoding': 'hex', 'setread': True} + msg = self.callrpc('smsg', [msg_id.hex(), options]) + self.processMsg(msg) + + def update(self): + try: + # while True: + message = self.zmqSubscriber.recv(flags=zmq.NOBLOCK) + if message == b'smsg': + self.processZmqSmsg() + except zmq.Again as e: + pass + except Exception as e: + self.log.error('smsg zmq %s', str(e)) + traceback.print_exc() + + self.mxDB.acquire() + try: + # TODO: Wait for blocks / txns, would need to check multiple coins + now = int(time.time()) + if now - self.last_checked_progress > self.check_progress_seconds: + to_remove = [] + for bid_id, v in self.swaps_in_progress.items(): + if self.checkBidState(bid_id, v[0], v[1]) is True: + to_remove.append(bid_id) + for bid_id in to_remove: + self.log.debug('Removing bid from in-progress: %s', bid_id.hex()) + del self.swaps_in_progress[bid_id] + self.last_checked_progress = now + + now = int(time.time()) + if now - self.last_checked_watched > self.check_watched_seconds: + for k, c in self.coin_clients.items(): + if len(c['watched_outputs']) > 0: + self.checkForSpends(k, c) + self.last_checked_watched = now + + # Expire messages + if int(time.time()) - self.last_checked_expired > self.check_expired_seconds: + self.expireMessages() + except Exception as e: + self.log.error('update %s', str(e)) + traceback.print_exc() + finally: + self.mxDB.release() + + def getSummary(self, opts=None): + num_watched_outputs = 0 + for c, v in self.coin_clients.items(): + num_watched_outputs += len(v['watched_outputs']) + + bids_sent = 0 + bids_received = 0 + q = self.engine.execute('SELECT was_sent, was_received, COUNT(*) FROM bids GROUP BY was_sent, was_received ') + for r in q: + if r[0]: + bids_sent += r[2] + if r[1]: + bids_received += r[2] + + now = int(time.time()) + q = self.engine.execute('SELECT COUNT(*) FROM offers WHERE expire_at > {}'.format(now)).first() + num_offers = q[0] + + q = self.engine.execute('SELECT COUNT(*) FROM offers WHERE was_sent = 1'.format(now)).first() + num_sent_offers = q[0] + + rv = { + 'network': self.chain, + 'num_swapping': len(self.swaps_in_progress), + 'num_network_offers': num_offers, + 'num_sent_offers': num_sent_offers, + 'num_recv_bids': bids_received, + 'num_sent_bids': bids_sent, + 'num_watched_outputs': num_watched_outputs, + } + return rv + + def getWalletInfo(self, coin): + + blockchaininfo = self.callcoinrpc(coin, 'getblockchaininfo') + walletinfo = self.callcoinrpc(coin, 'getwalletinfo') + rv = { + 'deposit_address': self.getCachedAddressForCoin(coin), + 'name': chainparams[coin]['name'].capitalize(), + 'blocks': blockchaininfo['blocks'], + 'balance': walletinfo.get('total_balance', walletinfo['balance']), + 'synced': '{0:.2f}'.format(round(blockchaininfo['verificationprogress'], 2)), + } + return rv + + def getWalletsInfo(self, opts=None): + rv = {} + for c in Coins: + if self.coin_clients[c]['connection_type'] == 'rpc': + rv[c] = self.getWalletInfo(c) + return rv + + def countAcceptedBids(self, offer_id=None): + self.mxDB.acquire() + try: + session = scoped_session(self.session_factory) + if offer_id: + q = self.engine.execute('SELECT COUNT(*) FROM bids WHERE state >= {} AND offer_id = x\'{}\''.format(BidStates.BID_ACCEPTED, offer_id.hex())).first() + else: + q = self.engine.execute('SELECT COUNT(*) FROM bids WHERE state >= {}'.format(BidStates.BID_ACCEPTED)).first() + return q[0] + finally: + session.close() + session.remove() + self.mxDB.release() + + def listOffers(self, sent=False): + self.mxDB.acquire() + try: + rv = [] + now = int(time.time()) + session = scoped_session(self.session_factory) + + if sent: + q = session.query(Offer).filter(Offer.was_sent == True).order_by(Offer.created_at.desc()) # noqa E712 + else: + q = session.query(Offer).filter(Offer.expire_at > now).order_by(Offer.created_at.desc()) + for row in q: + rv.append(row) + return rv + finally: + session.close() + session.remove() + self.mxDB.release() + + def listBids(self, sent=False, offer_id=None, for_html=False): + self.mxDB.acquire() + try: + rv = [] + now = int(time.time()) + session = scoped_session(self.session_factory) + if offer_id is not None: + q = session.query(Bid).filter(Bid.offer_id == offer_id) + elif sent: + q = session.query(Bid).filter(Bid.was_sent == True).order_by(Bid.created_at.desc()) # noqa E712 + else: + q = session.query(Bid).filter(Bid.was_received == True).order_by(Bid.created_at.desc()) # noqa E712 + for row in q: + rv.append(row) + return rv + finally: + session.close() + session.remove() + self.mxDB.release() + + def listSwapsInProgress(self, for_html=False): + self.mxDB.acquire() + try: + rv = [] + for k, v in self.swaps_in_progress.items(): + rv.append((k, v[0].offer_id.hex(), v[0].state)) + return rv + finally: + self.mxDB.release() + + def listWatchedOutputs(self): + self.mxDB.acquire() + try: + rv = [] + rv_heights = [] + for c, v in self.coin_clients.items(): + rv_heights.append((c, v['last_height_checked'])) + for o in v['watched_outputs']: + rv.append((c, o[0], o[1], o[2], o[3])) + return (rv, rv_heights) + finally: + self.mxDB.release() + + def callrpc(self, method, params=[], wallet=None): + return callrpc(self.coin_clients[Coins.PART]['rpcport'], self.coin_clients[Coins.PART]['rpcauth'], method, params, wallet) + + def callcoinrpc(self, coin, method, params=[], wallet=None): + return callrpc(self.coin_clients[coin]['rpcport'], self.coin_clients[coin]['rpcauth'], method, params, wallet) + + def calltx(self, cmd): + settings = self.getChainClientSettings(Coins.PART) + bindir = settings.get('bindir', '') + command_cli = os.path.join(bindir, cfg.PARTICL_TX) + chainname = '' if self.chain == 'mainnet' else (' -' + self.chain) + args = command_cli + chainname + ' ' + cmd + p = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True) + out = p.communicate() + if len(out[1]) > 0: + raise ValueError('TX error ' + str(out[1])) + return out[0].decode('utf-8').strip() diff --git a/basicswap/chainparams.py b/basicswap/chainparams.py new file mode 100644 index 0000000..ecc6f3d --- /dev/null +++ b/basicswap/chainparams.py @@ -0,0 +1,120 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2019 tecnovert +# Distributed under the MIT software license, see the accompanying +# file LICENSE.txt or http://www.opensource.org/licenses/mit-license.php. + +from enum import IntEnum +from .util import ( + COIN, +) + + +class Coins(IntEnum): + PART = 1 + BTC = 2 + LTC = 3 + # DCR = 4 + + +chainparams = { + Coins.PART: { + 'name': 'particl', + 'ticker': 'PART', + 'message_magic': 'Bitcoin Signed Message:\n', + 'mainnet': { + 'rpcport': 51735, + 'pubkey_address': 0x38, + 'script_address': 0x3c, + 'key_prefix': 0x6c, + 'hrp': 'pw', + 'bip44': 44, + 'min_amount': 1000, + 'max_amount': 100000 * COIN, + }, + 'testnet': { + 'rpcport': 51935, + 'pubkey_address': 0x76, + 'script_address': 0x7a, + 'key_prefix': 0x2e, + 'hrp': 'tpw', + 'bip44': 1, + 'min_amount': 1000, + 'max_amount': 100000 * COIN, + }, + 'regtest': { + 'rpcport': 51936, + 'pubkey_address': 0x76, + 'script_address': 0x7a, + 'key_prefix': 0x2e, + 'hrp': 'rtpw', + 'bip44': 1, + 'min_amount': 1000, + 'max_amount': 100000 * COIN, + } + }, + Coins.BTC: { + 'name': 'bitcoin', + 'ticker': 'BTC', + 'message_magic': 'Bitcoin Signed Message:\n', + 'mainnet': { + 'rpcport': 8332, + 'pubkey_address': 0, + 'script_address': 5, + 'hrp': 'bc', + 'bip44': 0, + 'min_amount': 1000, + 'max_amount': 100000 * COIN, + }, + 'testnet': { + 'rpcport': 18332, + 'pubkey_address': 111, + 'script_address': 196, + 'hrp': 'tb', + 'bip44': 1, + 'min_amount': 1000, + 'max_amount': 100000 * COIN, + }, + 'regtest': { + 'rpcport': 18443, + 'pubkey_address': 111, + 'script_address': 196, + 'hrp': 'bcrt', + 'bip44': 1, + 'min_amount': 1000, + 'max_amount': 100000 * COIN, + } + }, + Coins.LTC: { + 'name': 'litecoin', + 'ticker': 'LTC', + 'message_magic': 'Litecoin Signed Message:\n', + 'mainnet': { + 'rpcport': 9332, + 'pubkey_address': 48, + 'script_address': 50, + 'hrp': 'ltc', + 'bip44': 2, + 'min_amount': 1000, + 'max_amount': 100000 * COIN, + }, + 'testnet': { + 'rpcport': 19332, + 'pubkey_address': 111, + 'script_address': 58, + 'hrp': 'tltc', + 'bip44': 1, + 'min_amount': 1000, + 'max_amount': 100000 * COIN, + }, + 'regtest': { + 'rpcport': 19443, + 'pubkey_address': 111, + 'script_address': 58, + 'hrp': 'rltc', + 'bip44': 1, + 'min_amount': 1000, + 'max_amount': 100000 * COIN, + } + } +} diff --git a/basicswap/config.py b/basicswap/config.py new file mode 100644 index 0000000..78b88af --- /dev/null +++ b/basicswap/config.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2019 tecnovert +# Distributed under the MIT software license, see the accompanying +# file LICENSE.txt or http://www.opensource.org/licenses/mit-license.php. + +import os + +DATADIRS = os.path.expanduser(os.getenv('DATADIRS', '/tmp/basicswap')) + +PARTICL_BINDIR = os.path.expanduser(os.getenv('PARTICL_BINDIR', '')) +PARTICLD = os.getenv('PARTICLD', 'particld') +PARTICL_CLI = os.getenv('PARTICL_CLI', 'particl-cli') +PARTICL_TX = os.getenv('PARTICL_TX', 'particl-tx') + +BITCOIN_BINDIR = os.path.expanduser(os.getenv('BITCOIN_BINDIR', '')) +BITCOIND = os.getenv('BITCOIND', 'bitcoind') +BITCOIN_CLI = os.getenv('BITCOIN_CLI', 'bitcoin-cli') +BITCOIN_TX = os.getenv('BITCOIN_TX', 'bitcoin-tx') + +LITECOIN_BINDIR = os.path.expanduser(os.getenv('LITECOIN_BINDIR', '')) +LITECOIND = os.getenv('LITECOIND', 'litecoind') +LITECOIN_CLI = os.getenv('LITECOIN_CLI', 'litecoin-cli') +LITECOIN_TX = os.getenv('LITECOIN_TX', 'litecoin-tx') diff --git a/basicswap/http_server.py b/basicswap/http_server.py new file mode 100644 index 0000000..ff874c5 --- /dev/null +++ b/basicswap/http_server.py @@ -0,0 +1,542 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2019 tecnovert +# Distributed under the MIT software license, see the accompanying +# file LICENSE.txt or http://www.opensource.org/licenses/mit-license.php. + +import os +import json +import time +import struct +import traceback +import threading +import http.client +import urllib.parse +from http.server import BaseHTTPRequestHandler, HTTPServer +from .util import ( + COIN, + format8, +) +from .chainparams import ( + chainparams, + Coins, +) +from .basicswap import ( + SwapTypes, + getOfferState, + getBidState, + getTxState, + getLockName, +) + + +def getCoinName(c): + return chainparams[c]['name'].capitalize() + + +def html_content_start(title, h2=None): + content = '\n' \ + + '' \ + + '' + title + '' \ + + '' + if h2 is not None: + content += '

' + h2 + '

' + return content + + +class HttpHandler(BaseHTTPRequestHandler): + def page_error(self, error_str): + content = html_content_start('BasicSwap Error') \ + + '

Error: ' + error_str + '

' \ + + '

home

' + return bytes(content, 'UTF-8') + + def js_error(self, error_str): + error_str_json = json.dumps({'error': error_str}) + return bytes(error_str_json, 'UTF-8') + + def js_wallets(self, url_split): + return bytes(json.dumps(self.server.swap_client.getWalletsInfo()), 'UTF-8') + + def js_offers(self, url_split): + assert(False), 'TODO' + return bytes(json.dumps(self.server.swap_client.listOffers()), 'UTF-8') + + def js_sentoffers(self, url_split): + assert(False), 'TODO' + return bytes(json.dumps(self.server.swap_client.listOffers(sent=True)), 'UTF-8') + + def js_bids(self, url_split): + if len(url_split) > 3: + bid_id = bytes.fromhex(url_split[3]) + assert(len(bid_id) == 28) + return bytes(json.dumps(self.server.swap_client.viewBid(bid_id)), 'UTF-8') + return bytes(json.dumps(self.server.swap_client.listBids()), 'UTF-8') + + def js_sentbids(self, url_split): + return bytes(json.dumps(self.server.swap_client.listBids(sent=True)), 'UTF-8') + + def js_index(self, url_split): + return bytes(json.dumps(self.server.swap_client.getSummary()), 'UTF-8') + + def page_active(self, url_split, post_string): + swap_client = self.server.swap_client + + content = html_content_start(self.server.title, self.server.title) \ + + '

Active Swaps

' + + active_swaps = swap_client.listSwapsInProgress() + + content += '' + content += '' + for s in active_swaps: + content += ''.format(s[0].hex(), s[1], getBidState(s[2])) + content += '
Bid IDOffer IDBid Status
{0}{1}{2}
' + + content += '

home

' + return bytes(content, 'UTF-8') + + def page_wallets(self, url_split, post_string): + swap_client = self.server.swap_client + + content = html_content_start(self.server.title, self.server.title) \ + + '

Wallets

' + + if post_string != '': + form_data = urllib.parse.parse_qs(post_string) + form_id = form_data[b'formid'][0].decode('utf-8') + if self.server.last_form_id.get('wallets', None) == form_id: + content += '

Prevented double submit for form {}.

'.format(form_id) + else: + self.server.last_form_id['wallets'] = form_id + + for c in Coins: + cid = str(int(c)) + + if bytes('newaddr_' + cid, 'utf-8') in form_data: + swap_client.cacheNewAddressForCoin(c) + + if bytes('withdraw_' + cid, 'utf-8') in form_data: + value = form_data[bytes('amt_' + cid, 'utf-8')][0].decode('utf-8') + address = form_data[bytes('to_' + cid, 'utf-8')][0].decode('utf-8') + txid = swap_client.withdrawCoin(c, value, address) + ticker = swap_client.getTicker(c) + content += '

Withdrew {} {} to address {}
In txid: {}

'.format(value, ticker, address, txid) + + wallets = swap_client.getWalletsInfo() + + content += '
' + for k, w in wallets.items(): + cid = str(int(k)) + content += '

' + w['name'] + '

' \ + + '' \ + + '' \ + + '' \ + + '' \ + + '' \ + + '' \ + + '
Balance:' + str(w['balance']) + '
Blocks:' + str(w['blocks']) + '
Synced:' + str(w['synced']) + '
' + str(w['deposit_address']) + '
Amount: Address:
' + + content += '
' + content += '

home

' + return bytes(content, 'UTF-8') + + def make_coin_select(self, name, coins): + s = '' + return s + + def page_newoffer(self, url_split, post_string): + swap_client = self.server.swap_client + + content = html_content_start(self.server.title, self.server.title) \ + + '

New Offer

' + + if post_string != '': + form_data = urllib.parse.parse_qs(post_string) + form_id = form_data[b'formid'][0].decode('utf-8') + if self.server.last_form_id.get('newoffer', None) == form_id: + content += '

Prevented double submit for form {}.

'.format(form_id) + else: + self.server.last_form_id['newoffer'] = form_id + + try: + coin_from = Coins(int(form_data[b'coin_from'][0])) + except Exception: + raise ValueError('Unknown Coin From') + try: + coin_to = Coins(int(form_data[b'coin_to'][0])) + except Exception: + raise ValueError('Unknown Coin From') + + value_from = int(float(form_data[b'amt_from'][0]) * COIN) + value_to = int(float(form_data[b'amt_to'][0]) * COIN) + min_bid = int(value_from) + rate = int((value_to / value_from) * COIN) + autoaccept = True if b'autoaccept' in form_data else False + # TODO: More accurate rate + # assert(value_to == (value_from * rate) // COIN) + offer_id = swap_client.postOffer(coin_from, coin_to, value_from, rate, min_bid, SwapTypes.SELLER_FIRST, auto_accept_bids=autoaccept) + content += '

Sent Offer ' + offer_id.hex() + '
Rate: ' + format8(rate) + '

' + + coins = [] + + for k, v in swap_client.coin_clients.items(): + if v['connection_type'] == 'rpc': + coins.append((int(k), getCoinName(k))) + + content += '
' + + content += '' + content += '' + content += '' + content += '' + content += '
Coin From' + self.make_coin_select('coin_from', coins) + 'Amount From
Coin To' + self.make_coin_select('coin_to', coins) + 'Amount To
Auto Accept Bids
' + + content += '' + content += '
' + content += '

home

' + return bytes(content, 'UTF-8') + + def page_offer(self, url_split, post_string): + assert(len(url_split) > 2), 'Offer ID not specified' + try: + offer_id = bytes.fromhex(url_split[2]) + assert(len(offer_id) == 28) + except Exception: + raise ValueError('Bad offer ID') + swap_client = self.server.swap_client + offer = swap_client.getOffer(offer_id) + assert(offer), 'Unknown offer ID' + + content = html_content_start(self.server.title, self.server.title) \ + + '

Offer: ' + offer_id.hex() + '

' + + if post_string != '': + form_data = urllib.parse.parse_qs(post_string) + form_id = form_data[b'formid'][0].decode('utf-8') + if self.server.last_form_id.get('offer', None) == form_id: + content += '

Prevented double submit for form {}.

'.format(form_id) + else: + self.server.last_form_id['offer'] = form_id + bid_id = swap_client.postBid(offer_id, offer.amount_from) + content += '

Sent Bid ' + bid_id.hex() + '

' + + coin_from = Coins(offer.coin_from) + coin_to = Coins(offer.coin_to) + ticker_from = swap_client.getTicker(coin_from) + ticker_to = swap_client.getTicker(coin_to) + + tr = '{}{}' + content += '' + content += tr.format('Offer State', getOfferState(offer.state)) + content += tr.format('Coin From', getCoinName(coin_from)) + content += tr.format('Coin To', getCoinName(coin_to)) + content += tr.format('Amount From', format8(offer.amount_from) + ' ' + ticker_from) + content += tr.format('Amount To', format8((offer.amount_from * offer.rate) // COIN) + ' ' + ticker_to) + content += tr.format('Rate', format8(offer.rate) + ' ' + ticker_from + '/' + ticker_to) + content += tr.format('Script Lock Type', getLockName(offer.lock_type)) + content += tr.format('Script Lock Value', offer.lock_value) + content += tr.format('Address From', offer.addr_from) + content += tr.format('Created At', time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(offer.created_at))) + content += tr.format('Expired At', time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(offer.expire_at))) + content += tr.format('Sent', 'True' if offer.was_sent else 'False') + + if offer.was_sent: + content += tr.format('Auto Accept Bids', 'True' if offer.auto_accept_bids else 'False') + content += '
' + + bids = swap_client.listBids(offer_id=offer_id) + + content += '

Bids

' + content += '' + for b in bids: + content += ''.format(b.bid_id.hex(), format8(b.amount), getBidState(b.state), getTxState(b.initiate_txn_state), getTxState(b.participate_txn_state)) + content += '
Bid IDBid AmountBid StatusITX StatusPTX Status
{0}{1}{2}{3}{4}
' + + content += '
' + content += '' + content += '
' + content += '

home

' + return bytes(content, 'UTF-8') + + def page_offers(self, url_split, sent=False): + swap_client = self.server.swap_client + offers = swap_client.listOffers(sent) + + content = html_content_start(self.server.title, self.server.title) \ + + '

' + ('Sent ' if sent else '') + 'Offers

' + + content += '' + content += '' + for o in offers: + coin_from_name = getCoinName(Coins(o.coin_from)) + coin_to_name = getCoinName(Coins(o.coin_to)) + amount_to = (o.amount_from * o.rate) // COIN + content += ''.format(o.offer_id.hex(), coin_from_name, coin_to_name, format8(o.amount_from), format8(amount_to), format8(o.rate)) + + content += '
Offer IDCoin FromCoin ToAmount FromAmount ToRate
{0}{1}{2}{3}{4}{5}
' + content += '

home

' + return bytes(content, 'UTF-8') + + def page_bid(self, url_split, post_string): + assert(len(url_split) > 2), 'Bid ID not specified' + try: + bid_id = bytes.fromhex(url_split[2]) + assert(len(bid_id) == 28) + except Exception: + raise ValueError('Bad bid ID') + swap_client = self.server.swap_client + + content = html_content_start(self.server.title, self.server.title) \ + + '

Bid: ' + bid_id.hex() + '

' + + show_txns = False + if post_string != '': + form_data = urllib.parse.parse_qs(post_string) + form_id = form_data[b'formid'][0].decode('utf-8') + if self.server.last_form_id.get('bid', None) == form_id: + content += '

Prevented double submit for form {}.

'.format(form_id) + else: + self.server.last_form_id['bid'] = form_id + if b'abandon_bid' in form_data: + try: + swap_client.abandonBid(bid_id) + content += '

Bid abandoned

' + except Exception as e: + content += '

Error' + str(e) + '

' + if b'accept_bid' in form_data: + try: + swap_client.acceptBid(bid_id) + content += '

Bid accepted

' + except Exception as e: + content += '

Error' + str(e) + '

' + if b'show_txns' in form_data: + show_txns = True + + bid, offer = swap_client.getBidAndOffer(bid_id) + assert(bid), 'Unknown bid ID' + + coin_from = Coins(offer.coin_from) + coin_to = Coins(offer.coin_to) + ticker_from = swap_client.getTicker(coin_from) + ticker_to = swap_client.getTicker(coin_to) + + tr = '{}{}' + content += '' + + content += tr.format('Swap', format8(bid.amount) + ' ' + ticker_from + ' for ' + format8((bid.amount * offer.rate) // COIN) + ' ' + ticker_to) + content += tr.format('Bid State', getBidState(bid.state)) + content += tr.format('ITX State', getTxState(bid.initiate_txn_state)) + content += tr.format('PTX State', getTxState(bid.participate_txn_state)) + content += tr.format('Offer', '' + bid.offer_id.hex() + '') + content += tr.format('Address From', bid.bid_addr) + content += tr.format('Proof of Funds', bid.proof_address) + content += tr.format('Created At', time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(bid.created_at))) + content += tr.format('Expired At', time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(bid.expire_at))) + content += tr.format('Sent', 'True' if bid.was_sent else 'False') + content += tr.format('Received', 'True' if bid.was_received else 'False') + content += tr.format('Initiate Tx', 'None' if not bid.initiate_txid else bid.initiate_txid.hex()) + content += tr.format('Initiate Conf', 'None' if not bid.initiate_txn_conf else bid.initiate_txn_conf) + content += tr.format('Participate Tx', 'None' if not bid.participate_txid else bid.participate_txid.hex()) + content += tr.format('Participate Conf', 'None' if not bid.participate_txn_conf else bid.participate_txn_conf) + if show_txns: + content += tr.format('Initiate Tx Refund', 'None' if not bid.initiate_txn_refund else bid.initiate_txn_refund.hex()) + content += tr.format('Participate Tx Refund', 'None' if not bid.participate_txn_refund else bid.participate_txn_refund.hex()) + content += tr.format('Initiate Spend Tx', 'None' if not bid.initiate_spend_txid else (bid.initiate_spend_txid.hex() + ' {}'.format(bid.initiate_spend_n))) + content += tr.format('Participate Spend Tx', 'None' if not bid.participate_spend_txid else (bid.participate_spend_txid.hex() + ' {}'.format(bid.participate_spend_n))) + content += '
' + + content += '
' + if bid.was_received: + content += '
' + content += '' + content += '' + content += '
' + + content += '

Old States

' + num_states = len(bid.states) // 12 + for i in range(num_states): + up = struct.unpack_from('' + ('Sent ' if sent else '') + 'Bids' + + content += '
StateSet At
' + content += '' + for b in bids: + content += ''.format(b.bid_id.hex(), b.offer_id.hex(), getBidState(b.state), getTxState(b.initiate_txn_state), getTxState(b.participate_txn_state)) + content += '
Bid IDOffer IDBid StatusITX StatusPTX Status
{0}{1}{2}{3}{4}
' + + content += '

home

' + return bytes(content, 'UTF-8') + + def page_watched(self, url_split, post_string): + swap_client = self.server.swap_client + watched_outputs, last_scanned = swap_client.listWatchedOutputs() + + content = html_content_start(self.server.title, self.server.title) \ + + '

Watched Outputs

' + + for c in last_scanned: + content += '

' + getCoinName(c[0]) + ' Scanned Height: ' + str(c[1]) + '

' + + content += '' + content += '' + for o in watched_outputs: + content += ''.format(o[1].hex(), getCoinName(o[0]), o[2], o[3], int(o[4])) + content += '
Bid IDChainTxidIndexType
{0}{1}{2}{3}{4}
' + + content += '

home

' + return bytes(content, 'UTF-8') + + def page_index(self, url_split): + swap_client = self.server.swap_client + summary = swap_client.getSummary() + + content = html_content_start(self.server.title, self.server.title) \ + + '

View Wallets

' \ + + '

' \ + + 'Network: ' + str(summary['network']) + '
' \ + + 'Swaps in progress: ' + str(summary['num_swapping']) + '
' \ + + 'Network Offers: ' + str(summary['num_network_offers']) + '
' \ + + 'Sent Offers: ' + str(summary['num_sent_offers']) + '
' \ + + 'Received Bids: ' + str(summary['num_recv_bids']) + '
' \ + + 'Sent Bids: ' + str(summary['num_sent_bids']) + '
' \ + + 'Watched Outputs: ' + str(summary['num_watched_outputs']) + '
' \ + + '

' \ + + '

' \ + + 'New Offer
' \ + + '

' + content += '' + return bytes(content, 'UTF-8') + + def putHeaders(self, status_code, content_type): + self.send_response(status_code) + if self.server.allow_cors: + self.send_header('Access-Control-Allow-Origin', '*') + self.send_header('Content-type', content_type) + self.end_headers() + + def handle_http(self, status_code, path, post_string=''): + url_split = self.path.split('/') + if len(url_split) > 1 and url_split[1] == 'json': + try: + self.putHeaders(status_code, 'text/plain') + func = self.js_index + if len(url_split) > 2: + func = {'wallets': self.js_wallets, + 'offers': self.js_offers, + 'sentoffers': self.js_sentoffers, + 'bids': self.js_bids, + 'sentbids': self.js_sentbids, + }.get(url_split[2], self.js_index) + return func(url_split) + except Exception as e: + return self.js_error(str(e)) + try: + self.putHeaders(status_code, 'text/html') + if len(url_split) > 1: + if url_split[1] == 'active': + return self.page_active(url_split, post_string) + if url_split[1] == 'wallets': + return self.page_wallets(url_split, post_string) + if url_split[1] == 'offer': + return self.page_offer(url_split, post_string) + if url_split[1] == 'offers': + return self.page_offers(url_split) + if url_split[1] == 'newoffer': + return self.page_newoffer(url_split, post_string) + if url_split[1] == 'sentoffers': + return self.page_offers(url_split, sent=True) + if url_split[1] == 'bid': + return self.page_bid(url_split, post_string) + if url_split[1] == 'bids': + return self.page_bids(url_split, post_string) + if url_split[1] == 'sentbids': + return self.page_bids(url_split, post_string, sent=True) + if url_split[1] == 'watched': + return self.page_watched(url_split, post_string) + return self.page_index(url_split) + except Exception as e: + traceback.print_exc() # TODO: Remove + return self.page_error(str(e)) + + def do_GET(self): + response = self.handle_http(200, self.path) + self.wfile.write(response) + + def do_POST(self): + post_string = self.rfile.read(int(self.headers['Content-Length'])) + response = self.handle_http(200, self.path, post_string) + self.wfile.write(response) + + def do_HEAD(self): + self.putHeaders(200, 'text/html') + + def do_OPTIONS(self): + self.send_response(200, 'ok') + if self.server.allow_cors: + self.send_header('Access-Control-Allow-Origin', '*') + self.send_header('Access-Control-Allow-Headers', '*') + self.end_headers() + + +class HttpThread(threading.Thread, HTTPServer): + def __init__(self, fp, host_name, port_no, allow_cors, swap_client): + threading.Thread.__init__(self) + + self.stop_event = threading.Event() + self.fp = fp + self.host_name = host_name + self.port_no = port_no + self.allow_cors = allow_cors + self.swap_client = swap_client + self.title = 'Simple Atomic Swap Demo' + self.last_form_id = dict() + + self.timeout = 60 + HTTPServer.__init__(self, (self.host_name, self.port_no), HttpHandler) + + def stop(self): + self.stop_event.set() + + # Send fake request + conn = http.client.HTTPConnection(self.host_name, self.port_no) + conn.connect() + conn.request('GET', '/none') + response = conn.getresponse() + data = response.read() + conn.close() + + def stopped(self): + return self.stop_event.is_set() + + def serve_forever(self): + while not self.stopped(): + self.handle_request() + self.socket.close() + + def run(self): + self.serve_forever() diff --git a/basicswap/key.py b/basicswap/key.py new file mode 100644 index 0000000..912c0ca --- /dev/null +++ b/basicswap/key.py @@ -0,0 +1,386 @@ +# Copyright (c) 2019 Pieter Wuille +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. +"""Test-only secp256k1 elliptic curve implementation + +WARNING: This code is slow, uses bad randomness, does not properly protect +keys, and is trivially vulnerable to side channel attacks. Do not use for +anything but tests.""" +import random + +def modinv(a, n): + """Compute the modular inverse of a modulo n + + See https://en.wikipedia.org/wiki/Extended_Euclidean_algorithm#Modular_integers. + """ + t1, t2 = 0, 1 + r1, r2 = n, a + while r2 != 0: + q = r1 // r2 + t1, t2 = t2, t1 - q * t2 + r1, r2 = r2, r1 - q * r2 + if r1 > 1: + return None + if t1 < 0: + t1 += n + return t1 + +def jacobi_symbol(n, k): + """Compute the Jacobi symbol of n modulo k + + See http://en.wikipedia.org/wiki/Jacobi_symbol + + For our application k is always prime, so this is the same as the Legendre symbol.""" + assert k > 0 and k & 1, "jacobi symbol is only defined for positive odd k" + n %= k + t = 0 + while n != 0: + while n & 1 == 0: + n >>= 1 + r = k & 7 + t ^= (r == 3 or r == 5) + n, k = k, n + t ^= (n & k & 3 == 3) + n = n % k + if k == 1: + return -1 if t else 1 + return 0 + +def modsqrt(a, p): + """Compute the square root of a modulo p when p % 4 = 3. + + The Tonelli-Shanks algorithm can be used. See https://en.wikipedia.org/wiki/Tonelli-Shanks_algorithm + + Limiting this function to only work for p % 4 = 3 means we don't need to + iterate through the loop. The highest n such that p - 1 = 2^n Q with Q odd + is n = 1. Therefore Q = (p-1)/2 and sqrt = a^((Q+1)/2) = a^((p+1)/4) + + secp256k1's is defined over field of size 2**256 - 2**32 - 977, which is 3 mod 4. + """ + if p % 4 != 3: + raise NotImplementedError("modsqrt only implemented for p % 4 = 3") + sqrt = pow(a, (p + 1)//4, p) + if pow(sqrt, 2, p) == a % p: + return sqrt + return None + +class EllipticCurve: + def __init__(self, p, a, b): + """Initialize elliptic curve y^2 = x^3 + a*x + b over GF(p).""" + self.p = p + self.a = a % p + self.b = b % p + + def affine(self, p1): + """Convert a Jacobian point tuple p1 to affine form, or None if at infinity. + + An affine point is represented as the Jacobian (x, y, 1)""" + x1, y1, z1 = p1 + if z1 == 0: + return None + inv = modinv(z1, self.p) + inv_2 = (inv**2) % self.p + inv_3 = (inv_2 * inv) % self.p + return ((inv_2 * x1) % self.p, (inv_3 * y1) % self.p, 1) + + def negate(self, p1): + """Negate a Jacobian point tuple p1.""" + x1, y1, z1 = p1 + return (x1, (self.p - y1) % self.p, z1) + + def on_curve(self, p1): + """Determine whether a Jacobian tuple p is on the curve (and not infinity)""" + x1, y1, z1 = p1 + z2 = pow(z1, 2, self.p) + z4 = pow(z2, 2, self.p) + return z1 != 0 and (pow(x1, 3, self.p) + self.a * x1 * z4 + self.b * z2 * z4 - pow(y1, 2, self.p)) % self.p == 0 + + def is_x_coord(self, x): + """Test whether x is a valid X coordinate on the curve.""" + x_3 = pow(x, 3, self.p) + return jacobi_symbol(x_3 + self.a * x + self.b, self.p) != -1 + + def lift_x(self, x): + """Given an X coordinate on the curve, return a corresponding affine point.""" + x_3 = pow(x, 3, self.p) + v = x_3 + self.a * x + self.b + y = modsqrt(v, self.p) + if y is None: + return None + return (x, y, 1) + + def double(self, p1): + """Double a Jacobian tuple p1 + + See https://en.wikibooks.org/wiki/Cryptography/Prime_Curve/Jacobian_Coordinates - Point Doubling""" + x1, y1, z1 = p1 + if z1 == 0: + return (0, 1, 0) + y1_2 = (y1**2) % self.p + y1_4 = (y1_2**2) % self.p + x1_2 = (x1**2) % self.p + s = (4*x1*y1_2) % self.p + m = 3*x1_2 + if self.a: + m += self.a * pow(z1, 4, self.p) + m = m % self.p + x2 = (m**2 - 2*s) % self.p + y2 = (m*(s - x2) - 8*y1_4) % self.p + z2 = (2*y1*z1) % self.p + return (x2, y2, z2) + + def add_mixed(self, p1, p2): + """Add a Jacobian tuple p1 and an affine tuple p2 + + See https://en.wikibooks.org/wiki/Cryptography/Prime_Curve/Jacobian_Coordinates - Point Addition (with affine point)""" + x1, y1, z1 = p1 + x2, y2, z2 = p2 + assert(z2 == 1) + # Adding to the point at infinity is a no-op + if z1 == 0: + return p2 + z1_2 = (z1**2) % self.p + z1_3 = (z1_2 * z1) % self.p + u2 = (x2 * z1_2) % self.p + s2 = (y2 * z1_3) % self.p + if x1 == u2: + if (y1 != s2): + # p1 and p2 are inverses. Return the point at infinity. + return (0, 1, 0) + # p1 == p2. The formulas below fail when the two points are equal. + return self.double(p1) + h = u2 - x1 + r = s2 - y1 + h_2 = (h**2) % self.p + h_3 = (h_2 * h) % self.p + u1_h_2 = (x1 * h_2) % self.p + x3 = (r**2 - h_3 - 2*u1_h_2) % self.p + y3 = (r*(u1_h_2 - x3) - y1*h_3) % self.p + z3 = (h*z1) % self.p + return (x3, y3, z3) + + def add(self, p1, p2): + """Add two Jacobian tuples p1 and p2 + + See https://en.wikibooks.org/wiki/Cryptography/Prime_Curve/Jacobian_Coordinates - Point Addition""" + x1, y1, z1 = p1 + x2, y2, z2 = p2 + # Adding the point at infinity is a no-op + if z1 == 0: + return p2 + if z2 == 0: + return p1 + # Adding an Affine to a Jacobian is more efficient since we save field multiplications and squarings when z = 1 + if z1 == 1: + return self.add_mixed(p2, p1) + if z2 == 1: + return self.add_mixed(p1, p2) + z1_2 = (z1**2) % self.p + z1_3 = (z1_2 * z1) % self.p + z2_2 = (z2**2) % self.p + z2_3 = (z2_2 * z2) % self.p + u1 = (x1 * z2_2) % self.p + u2 = (x2 * z1_2) % self.p + s1 = (y1 * z2_3) % self.p + s2 = (y2 * z1_3) % self.p + if u1 == u2: + if (s1 != s2): + # p1 and p2 are inverses. Return the point at infinity. + return (0, 1, 0) + # p1 == p2. The formulas below fail when the two points are equal. + return self.double(p1) + h = u2 - u1 + r = s2 - s1 + h_2 = (h**2) % self.p + h_3 = (h_2 * h) % self.p + u1_h_2 = (u1 * h_2) % self.p + x3 = (r**2 - h_3 - 2*u1_h_2) % self.p + y3 = (r*(u1_h_2 - x3) - s1*h_3) % self.p + z3 = (h*z1*z2) % self.p + return (x3, y3, z3) + + def mul(self, ps): + """Compute a (multi) point multiplication + + ps is a list of (Jacobian tuple, scalar) pairs. + """ + r = (0, 1, 0) + for i in range(255, -1, -1): + r = self.double(r) + for (p, n) in ps: + if ((n >> i) & 1): + r = self.add(r, p) + return r + +SECP256K1 = EllipticCurve(2**256 - 2**32 - 977, 0, 7) +SECP256K1_G = (0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798, 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8, 1) +SECP256K1_ORDER = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 +SECP256K1_ORDER_HALF = SECP256K1_ORDER // 2 + +class ECPubKey(): + """A secp256k1 public key""" + + def __init__(self): + """Construct an uninitialized public key""" + self.valid = False + + def set(self, data): + """Construct a public key from a serialization in compressed or uncompressed format""" + if (len(data) == 65 and data[0] == 0x04): + p = (int.from_bytes(data[1:33], 'big'), int.from_bytes(data[33:65], 'big'), 1) + self.valid = SECP256K1.on_curve(p) + if self.valid: + self.p = p + self.compressed = False + elif (len(data) == 33 and (data[0] == 0x02 or data[0] == 0x03)): + x = int.from_bytes(data[1:33], 'big') + if SECP256K1.is_x_coord(x): + p = SECP256K1.lift_x(x) + # if the oddness of the y co-ord isn't correct, find the other + # valid y + if (p[1] & 1) != (data[0] & 1): + p = SECP256K1.negate(p) + self.p = p + self.valid = True + self.compressed = True + else: + self.valid = False + else: + self.valid = False + + @property + def is_compressed(self): + return self.compressed + + @property + def is_valid(self): + return self.valid + + def get_bytes(self): + assert(self.valid) + p = SECP256K1.affine(self.p) + if p is None: + return None + if self.compressed: + return bytes([0x02 + (p[1] & 1)]) + p[0].to_bytes(32, 'big') + else: + return bytes([0x04]) + p[0].to_bytes(32, 'big') + p[1].to_bytes(32, 'big') + + def verify_ecdsa(self, sig, msg, low_s=True): + """Verify a strictly DER-encoded ECDSA signature against this pubkey. + + See https://en.wikipedia.org/wiki/Elliptic_Curve_Digital_Signature_Algorithm for the + ECDSA verifier algorithm""" + assert(self.valid) + + # Extract r and s from the DER formatted signature. Return false for + # any DER encoding errors. + if (sig[1] + 2 != len(sig)): + return False + if (len(sig) < 4): + return False + if (sig[0] != 0x30): + return False + if (sig[2] != 0x02): + return False + rlen = sig[3] + if (len(sig) < 6 + rlen): + return False + if rlen < 1 or rlen > 33: + return False + if sig[4] >= 0x80: + return False + if (rlen > 1 and (sig[4] == 0) and not (sig[5] & 0x80)): + return False + r = int.from_bytes(sig[4:4+rlen], 'big') + if (sig[4+rlen] != 0x02): + return False + slen = sig[5+rlen] + if slen < 1 or slen > 33: + return False + if (len(sig) != 6 + rlen + slen): + return False + if sig[6+rlen] >= 0x80: + return False + if (slen > 1 and (sig[6+rlen] == 0) and not (sig[7+rlen] & 0x80)): + return False + s = int.from_bytes(sig[6+rlen:6+rlen+slen], 'big') + + # Verify that r and s are within the group order + if r < 1 or s < 1 or r >= SECP256K1_ORDER or s >= SECP256K1_ORDER: + return False + if low_s and s >= SECP256K1_ORDER_HALF: + return False + z = int.from_bytes(msg, 'big') + + # Run verifier algorithm on r, s + w = modinv(s, SECP256K1_ORDER) + u1 = z*w % SECP256K1_ORDER + u2 = r*w % SECP256K1_ORDER + R = SECP256K1.affine(SECP256K1.mul([(SECP256K1_G, u1), (self.p, u2)])) + if R is None or R[0] != r: + return False + return True + +class ECKey(): + """A secp256k1 private key""" + + def __init__(self): + self.valid = False + + def set(self, secret, compressed): + """Construct a private key object with given 32-byte secret and compressed flag.""" + assert(len(secret) == 32) + secret = int.from_bytes(secret, 'big') + self.valid = (secret > 0 and secret < SECP256K1_ORDER) + if self.valid: + self.secret = secret + self.compressed = compressed + + def generate(self, compressed=True): + """Generate a random private key (compressed or uncompressed).""" + self.set(random.randrange(1, SECP256K1_ORDER).to_bytes(32, 'big'), compressed) + + def get_bytes(self): + """Retrieve the 32-byte representation of this key.""" + assert(self.valid) + return self.secret.to_bytes(32, 'big') + + @property + def is_valid(self): + return self.valid + + @property + def is_compressed(self): + return self.compressed + + def get_pubkey(self): + """Compute an ECPubKey object for this secret key.""" + assert(self.valid) + ret = ECPubKey() + p = SECP256K1.mul([(SECP256K1_G, self.secret)]) + ret.p = p + ret.valid = True + ret.compressed = self.compressed + return ret + + def sign_ecdsa(self, msg, low_s=True): + """Construct a DER-encoded ECDSA signature with this key. + + See https://en.wikipedia.org/wiki/Elliptic_Curve_Digital_Signature_Algorithm for the + ECDSA signer algorithm.""" + assert(self.valid) + z = int.from_bytes(msg, 'big') + # Note: no RFC6979, but a simple random nonce (some tests rely on distinct transactions for the same operation) + k = random.randrange(1, SECP256K1_ORDER) + R = SECP256K1.affine(SECP256K1.mul([(SECP256K1_G, k)])) + r = R[0] % SECP256K1_ORDER + s = (modinv(k, SECP256K1_ORDER) * (z + self.secret * r)) % SECP256K1_ORDER + if low_s and s > SECP256K1_ORDER_HALF: + s = SECP256K1_ORDER - s + # Represent in DER format. The byte representations of r and s have + # length rounded up (255 bits becomes 32 bytes and 256 bits becomes 33 + # bytes). + rb = r.to_bytes((r.bit_length() + 8) // 8, 'big') + sb = s.to_bytes((s.bit_length() + 8) // 8, 'big') + return b'\x30' + bytes([4 + len(rb) + len(sb), 2, len(rb)]) + rb + bytes([2, len(sb)]) + sb diff --git a/basicswap/messages.proto b/basicswap/messages.proto new file mode 100644 index 0000000..dce1f14 --- /dev/null +++ b/basicswap/messages.proto @@ -0,0 +1,46 @@ +syntax = "proto3"; + +package basicswap; + +/* Step 1, seller -> network */ +message OfferMessage { + uint32 coin_from = 1; + uint32 coin_to = 2; + uint64 amount_from = 3; + uint64 rate = 4; + uint64 min_bid_amount = 5; + uint64 time_valid = 6; + enum LockType { + NOT_SET = 0; + SEQUENCE_LOCK_BLOCKS = 1; + SEQUENCE_LOCK_TIME = 2; + } + LockType lock_type = 7; + uint32 lock_value = 8; + uint32 swap_type = 9; + + /* optional */ + string proof_address = 10; + string proof_signature = 11; + bytes pkhash_seller = 12; + bytes secret_hash = 13; +} + +/* Step 2, buyer -> seller */ +message BidMessage { + bytes offer_msg_id = 1; + uint64 time_valid = 2; /* seconds bid is valid for */ + uint64 amount = 3; /* amount of amount_from bid is for */ + + /* optional */ + bytes pkhash_buyer = 4; /* buyer's address to receive amount_from */ + string proof_address = 5; + string proof_signature = 6; +} + +/* Step 3, seller -> buyer */ +message BidAcceptMessage { + bytes bid_msg_id = 1; + bytes initiate_txid = 2; + bytes contract_script = 3; +} diff --git a/basicswap/messages_pb2.py b/basicswap/messages_pb2.py new file mode 100644 index 0000000..a31221a --- /dev/null +++ b/basicswap/messages_pb2.py @@ -0,0 +1,310 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: messages.proto + +import sys +_b=sys.version_info[0]<3 and (lambda x:x) or (lambda x:x.encode('latin1')) +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from google.protobuf import reflection as _reflection +from google.protobuf import symbol_database as _symbol_database +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor.FileDescriptor( + name='messages.proto', + package='basicswap', + syntax='proto3', + serialized_options=None, + serialized_pb=_b('\n\x0emessages.proto\x12\tbasicswap\"\x84\x03\n\x0cOfferMessage\x12\x11\n\tcoin_from\x18\x01 \x01(\r\x12\x0f\n\x07\x63oin_to\x18\x02 \x01(\r\x12\x13\n\x0b\x61mount_from\x18\x03 \x01(\x04\x12\x0c\n\x04rate\x18\x04 \x01(\x04\x12\x16\n\x0emin_bid_amount\x18\x05 \x01(\x04\x12\x12\n\ntime_valid\x18\x06 \x01(\x04\x12\x33\n\tlock_type\x18\x07 \x01(\x0e\x32 .basicswap.OfferMessage.LockType\x12\x12\n\nlock_value\x18\x08 \x01(\r\x12\x11\n\tswap_type\x18\t \x01(\r\x12\x15\n\rproof_address\x18\n \x01(\t\x12\x17\n\x0fproof_signature\x18\x0b \x01(\t\x12\x15\n\rpkhash_seller\x18\x0c \x01(\x0c\x12\x13\n\x0bsecret_hash\x18\r \x01(\x0c\"I\n\x08LockType\x12\x0b\n\x07NOT_SET\x10\x00\x12\x18\n\x14SEQUENCE_LOCK_BLOCKS\x10\x01\x12\x16\n\x12SEQUENCE_LOCK_TIME\x10\x02\"\x8c\x01\n\nBidMessage\x12\x14\n\x0coffer_msg_id\x18\x01 \x01(\x0c\x12\x12\n\ntime_valid\x18\x02 \x01(\x04\x12\x0e\n\x06\x61mount\x18\x03 \x01(\x04\x12\x14\n\x0cpkhash_buyer\x18\x04 \x01(\x0c\x12\x15\n\rproof_address\x18\x05 \x01(\t\x12\x17\n\x0fproof_signature\x18\x06 \x01(\t\"V\n\x10\x42idAcceptMessage\x12\x12\n\nbid_msg_id\x18\x01 \x01(\x0c\x12\x15\n\rinitiate_txid\x18\x02 \x01(\x0c\x12\x17\n\x0f\x63ontract_script\x18\x03 \x01(\x0c\x62\x06proto3') +) + + + +_OFFERMESSAGE_LOCKTYPE = _descriptor.EnumDescriptor( + name='LockType', + full_name='basicswap.OfferMessage.LockType', + filename=None, + file=DESCRIPTOR, + values=[ + _descriptor.EnumValueDescriptor( + name='NOT_SET', index=0, number=0, + serialized_options=None, + type=None), + _descriptor.EnumValueDescriptor( + name='SEQUENCE_LOCK_BLOCKS', index=1, number=1, + serialized_options=None, + type=None), + _descriptor.EnumValueDescriptor( + name='SEQUENCE_LOCK_TIME', index=2, number=2, + serialized_options=None, + type=None), + ], + containing_type=None, + serialized_options=None, + serialized_start=345, + serialized_end=418, +) +_sym_db.RegisterEnumDescriptor(_OFFERMESSAGE_LOCKTYPE) + + +_OFFERMESSAGE = _descriptor.Descriptor( + name='OfferMessage', + full_name='basicswap.OfferMessage', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='coin_from', full_name='basicswap.OfferMessage.coin_from', index=0, + number=1, type=13, cpp_type=3, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='coin_to', full_name='basicswap.OfferMessage.coin_to', index=1, + number=2, type=13, cpp_type=3, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='amount_from', full_name='basicswap.OfferMessage.amount_from', index=2, + number=3, type=4, cpp_type=4, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='rate', full_name='basicswap.OfferMessage.rate', index=3, + number=4, type=4, cpp_type=4, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='min_bid_amount', full_name='basicswap.OfferMessage.min_bid_amount', index=4, + number=5, type=4, cpp_type=4, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='time_valid', full_name='basicswap.OfferMessage.time_valid', index=5, + number=6, type=4, cpp_type=4, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='lock_type', full_name='basicswap.OfferMessage.lock_type', index=6, + number=7, type=14, cpp_type=8, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='lock_value', full_name='basicswap.OfferMessage.lock_value', index=7, + number=8, type=13, cpp_type=3, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='swap_type', full_name='basicswap.OfferMessage.swap_type', index=8, + number=9, type=13, cpp_type=3, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='proof_address', full_name='basicswap.OfferMessage.proof_address', index=9, + number=10, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='proof_signature', full_name='basicswap.OfferMessage.proof_signature', index=10, + number=11, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='pkhash_seller', full_name='basicswap.OfferMessage.pkhash_seller', index=11, + number=12, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='secret_hash', full_name='basicswap.OfferMessage.secret_hash', index=12, + number=13, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + _OFFERMESSAGE_LOCKTYPE, + ], + serialized_options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=30, + serialized_end=418, +) + + +_BIDMESSAGE = _descriptor.Descriptor( + name='BidMessage', + full_name='basicswap.BidMessage', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='offer_msg_id', full_name='basicswap.BidMessage.offer_msg_id', index=0, + number=1, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='time_valid', full_name='basicswap.BidMessage.time_valid', index=1, + number=2, type=4, cpp_type=4, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='amount', full_name='basicswap.BidMessage.amount', index=2, + number=3, type=4, cpp_type=4, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='pkhash_buyer', full_name='basicswap.BidMessage.pkhash_buyer', index=3, + number=4, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='proof_address', full_name='basicswap.BidMessage.proof_address', index=4, + number=5, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='proof_signature', full_name='basicswap.BidMessage.proof_signature', index=5, + number=6, type=9, cpp_type=9, label=1, + has_default_value=False, default_value=_b("").decode('utf-8'), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + serialized_options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=421, + serialized_end=561, +) + + +_BIDACCEPTMESSAGE = _descriptor.Descriptor( + name='BidAcceptMessage', + full_name='basicswap.BidAcceptMessage', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='bid_msg_id', full_name='basicswap.BidAcceptMessage.bid_msg_id', index=0, + number=1, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='initiate_txid', full_name='basicswap.BidAcceptMessage.initiate_txid', index=1, + number=2, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='contract_script', full_name='basicswap.BidAcceptMessage.contract_script', index=2, + number=3, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + serialized_options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + serialized_options=None, + is_extendable=False, + syntax='proto3', + extension_ranges=[], + oneofs=[ + ], + serialized_start=563, + serialized_end=649, +) + +_OFFERMESSAGE.fields_by_name['lock_type'].enum_type = _OFFERMESSAGE_LOCKTYPE +_OFFERMESSAGE_LOCKTYPE.containing_type = _OFFERMESSAGE +DESCRIPTOR.message_types_by_name['OfferMessage'] = _OFFERMESSAGE +DESCRIPTOR.message_types_by_name['BidMessage'] = _BIDMESSAGE +DESCRIPTOR.message_types_by_name['BidAcceptMessage'] = _BIDACCEPTMESSAGE +_sym_db.RegisterFileDescriptor(DESCRIPTOR) + +OfferMessage = _reflection.GeneratedProtocolMessageType('OfferMessage', (_message.Message,), dict( + DESCRIPTOR = _OFFERMESSAGE, + __module__ = 'messages_pb2' + # @@protoc_insertion_point(class_scope:basicswap.OfferMessage) + )) +_sym_db.RegisterMessage(OfferMessage) + +BidMessage = _reflection.GeneratedProtocolMessageType('BidMessage', (_message.Message,), dict( + DESCRIPTOR = _BIDMESSAGE, + __module__ = 'messages_pb2' + # @@protoc_insertion_point(class_scope:basicswap.BidMessage) + )) +_sym_db.RegisterMessage(BidMessage) + +BidAcceptMessage = _reflection.GeneratedProtocolMessageType('BidAcceptMessage', (_message.Message,), dict( + DESCRIPTOR = _BIDACCEPTMESSAGE, + __module__ = 'messages_pb2' + # @@protoc_insertion_point(class_scope:basicswap.BidAcceptMessage) + )) +_sym_db.RegisterMessage(BidAcceptMessage) + + +# @@protoc_insertion_point(module_scope) diff --git a/basicswap/segwit_addr.py b/basicswap/segwit_addr.py new file mode 100644 index 0000000..68f2468 --- /dev/null +++ b/basicswap/segwit_addr.py @@ -0,0 +1,123 @@ +# Copyright (c) 2017 Pieter Wuille +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +"""Reference implementation for Bech32 and segwit addresses.""" + + +CHARSET = "qpzry9x8gf2tvdw0s3jn54khce6mua7l" + + +def bech32_polymod(values): + """Internal function that computes the Bech32 checksum.""" + generator = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3] + chk = 1 + for value in values: + top = chk >> 25 + chk = (chk & 0x1ffffff) << 5 ^ value + for i in range(5): + chk ^= generator[i] if ((top >> i) & 1) else 0 + return chk + + +def bech32_hrp_expand(hrp): + """Expand the HRP into values for checksum computation.""" + return [ord(x) >> 5 for x in hrp] + [0] + [ord(x) & 31 for x in hrp] + + +def bech32_verify_checksum(hrp, data): + """Verify a checksum given HRP and converted data characters.""" + return bech32_polymod(bech32_hrp_expand(hrp) + data) == 1 + + +def bech32_create_checksum(hrp, data): + """Compute the checksum values given HRP and data.""" + values = bech32_hrp_expand(hrp) + data + polymod = bech32_polymod(values + [0, 0, 0, 0, 0, 0]) ^ 1 + return [(polymod >> 5 * (5 - i)) & 31 for i in range(6)] + + +def bech32_encode(hrp, data): + """Compute a Bech32 string given HRP and data values.""" + combined = data + bech32_create_checksum(hrp, data) + return hrp + '1' + ''.join([CHARSET[d] for d in combined]) + + +def bech32_decode(bech): + """Validate a Bech32 string, and determine HRP and data.""" + if ((any(ord(x) < 33 or ord(x) > 126 for x in bech)) or + (bech.lower() != bech and bech.upper() != bech)): + return (None, None) + bech = bech.lower() + pos = bech.rfind('1') + if pos < 1 or pos + 7 > len(bech) or len(bech) > 90: + return (None, None) + if not all(x in CHARSET for x in bech[pos + 1:]): + return (None, None) + hrp = bech[:pos] + data = [CHARSET.find(x) for x in bech[pos + 1:]] + if not bech32_verify_checksum(hrp, data): + return (None, None) + return (hrp, data[:-6]) + + +def convertbits(data, frombits, tobits, pad=True): + """General power-of-2 base conversion.""" + acc = 0 + bits = 0 + ret = [] + maxv = (1 << tobits) - 1 + max_acc = (1 << (frombits + tobits - 1)) - 1 + for value in data: + if value < 0 or (value >> frombits): + return None + acc = ((acc << frombits) | value) & max_acc + bits += frombits + while bits >= tobits: + bits -= tobits + ret.append((acc >> bits) & maxv) + if pad: + if bits: + ret.append((acc << (tobits - bits)) & maxv) + elif bits >= frombits or ((acc << (tobits - bits)) & maxv): + return None + return ret + + +def decode(hrp, addr): + """Decode a segwit address.""" + hrpgot, data = bech32_decode(addr) + if hrpgot != hrp: + return (None, None) + decoded = convertbits(data[1:], 5, 8, False) + if decoded is None or len(decoded) < 2 or len(decoded) > 40: + return (None, None) + if data[0] > 16: + return (None, None) + if data[0] == 0 and len(decoded) != 20 and len(decoded) != 32: + return (None, None) + return (data[0], decoded) + + +def encode(hrp, witver, witprog): + """Encode a segwit address.""" + ret = bech32_encode(hrp, [witver] + convertbits(witprog, 8, 5)) + if decode(hrp, ret) == (None, None): + return None + return ret diff --git a/basicswap/util.py b/basicswap/util.py new file mode 100644 index 0000000..d63aec2 --- /dev/null +++ b/basicswap/util.py @@ -0,0 +1,292 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2018-2019 tecnovert +# Distributed under the MIT software license, see the accompanying +# file LICENSE.txt or http://www.opensource.org/licenses/mit-license.php. + +import os +import decimal +import subprocess +import json +import traceback +import hashlib +import urllib +from xmlrpc.client import ( + Transport, + Fault, +) +from .segwit_addr import bech32_decode, convertbits, bech32_encode + +COIN = 100000000 + + +def format8(i): + n = abs(i) + quotient = n // COIN + remainder = n % COIN + rv = "%d.%08d" % (quotient, remainder) + if i < 0: + rv = '-' + rv + return rv + + +def toBool(s): + return s.lower() in ["1", "true"] + + +def dquantize(n, places=8): + return n.quantize(decimal.Decimal(10) ** -places) + + +def jsonDecimal(obj): + if isinstance(obj, decimal.Decimal): + return str(obj) + raise TypeError + + +def dumpj(jin, indent=4): + return json.dumps(jin, indent=indent, default=jsonDecimal) + + +def dumpje(jin): + return json.dumps(jin, default=jsonDecimal).replace('"', '\\"') + + +__b58chars = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' + + +def b58decode(v, length=None): + long_value = 0 + for (i, c) in enumerate(v[::-1]): + ofs = __b58chars.find(c) + if ofs < 0: + return None + long_value += ofs * (58**i) + result = bytes() + while long_value >= 256: + div, mod = divmod(long_value, 256) + result = bytes((mod,)) + result + long_value = div + result = bytes((long_value,)) + result + nPad = 0 + for c in v: + if c == __b58chars[0]: + nPad += 1 + else: + break + pad = bytes((0,)) * nPad + result = pad + result + if length is not None and len(result) != length: + return None + return result + + +def b58encode(v): + long_value = 0 + for (i, c) in enumerate(v[::-1]): + long_value += (256**i) * c + + result = '' + while long_value >= 58: + div, mod = divmod(long_value, 58) + result = __b58chars[mod] + result + long_value = div + result = __b58chars[long_value] + result + + # leading 0-bytes in the input become leading-1s + nPad = 0 + for c in v: + if c == 0: + nPad += 1 + else: + break + return (__b58chars[0] * nPad) + result + + +def decodeWif(network_key): + key = b58decode(network_key)[1:-4] + if len(key) == 33: + return key[:-1] + return key + + +def toWIF(prefix_byte, b, compressed=True): + b = bytes((prefix_byte, )) + b + if compressed: + b += bytes((0x01, )) + b += hashlib.sha256(hashlib.sha256(b).digest()).digest()[:4] + return b58encode(b) + + +def bech32Decode(hrp, addr): + hrpgot, data = bech32_decode(addr) + if hrpgot != hrp: + return None + decoded = convertbits(data, 5, 8, False) + if decoded is None or len(decoded) < 2 or len(decoded) > 40: + return None + return bytes(decoded) + + +def bech32Encode(hrp, data): + ret = bech32_encode(hrp, convertbits(data, 8, 5)) + if bech32Decode(hrp, ret) is None: + return None + return ret + + +def decodeAddress(address_str): + b58_addr = b58decode(address_str) + if b58_addr is not None: + address = b58_addr[:-4] + checksum = b58_addr[-4:] + assert(hashlib.sha256(hashlib.sha256(address).digest()).digest()[:4] == checksum), 'Checksum mismatch' + return b58_addr[:-4] + return None + + +def encodeAddress(address): + checksum = hashlib.sha256(hashlib.sha256(address).digest()).digest() + return b58encode(address + checksum[0:4]) + + +def getKeyID(bytes): + data = hashlib.sha256(bytes).digest() + return hashlib.new("ripemd160", data).digest() + + +def pubkeyToAddress(prefix, pubkey): + return encodeAddress(bytes((prefix,)) + getKeyID(pubkey)) + + +def SerialiseNum(n): + if n == 0: + return bytes([0x00]) + if n > 0 and n <= 16: + return bytes([0x50 + n]) + rv = bytearray() + neg = n < 0 + absvalue = -n if neg else n + while(absvalue): + rv.append(absvalue & 0xff) + absvalue >>= 8 + if rv[-1] & 0x80: + rv.append(0x80 if neg else 0) + elif neg: + rv[-1] |= 0x80 + return bytes([len(rv)]) + rv + + +def DeserialiseNum(b, o=0): + if b[o] == 0: + return 0 + if b[o] > 0x50 and b[o] <= 0x50 + 16: + return b[o] - 0x50 + v = 0 + nb = b[o] + o += 1 + for i in range(0, nb): + v |= b[o + i] << (8 * i) + # If the input vector's most significant byte is 0x80, remove it from the result's msb and return a negative. + if b[o + nb - 1] & 0x80: + return -(v & ~(0x80 << (8 * (nb - 1)))) + return v + + +class Jsonrpc(): + # __getattr__ complicates extending ServerProxy + def __init__(self, uri, transport=None, encoding=None, verbose=False, + allow_none=False, use_datetime=False, use_builtin_types=False, + *, context=None): + # establish a "logical" server connection + + # get the url + type, uri = urllib.parse.splittype(uri) + if type not in ("http", "https"): + raise OSError("unsupported XML-RPC protocol") + self.__host, self.__handler = urllib.parse.splithost(uri) + if not self.__handler: + self.__handler = "/RPC2" + + if transport is None: + handler = Transport + extra_kwargs = {} + transport = handler(use_datetime=use_datetime, + use_builtin_types=use_builtin_types, + **extra_kwargs) + self.__transport = transport + + self.__encoding = encoding or 'utf-8' + self.__verbose = verbose + self.__allow_none = allow_none + + def close(self): + if self.__transport is not None: + self.__transport.close() + + def json_request(self, method, params): + try: + connection = self.__transport.make_connection(self.__host) + headers = self.__transport._extra_headers[:] + + request_body = { + 'method': method, + 'params': params, + 'id': 2 + } + + connection.putrequest("POST", self.__handler) + headers.append(("Content-Type", "application/json")) + headers.append(("User-Agent", 'jsonrpc')) + self.__transport.send_headers(connection, headers) + self.__transport.send_content(connection, json.dumps(request_body, default=jsonDecimal).encode('utf-8')) + + resp = connection.getresponse() + return resp.read() + + except Fault: + raise + except Exception: + # All unexpected errors leave connection in + # a strange state, so we clear it. + self.__transport.close() + raise + + +def callrpc(rpc_port, auth, method, params=[], wallet=None): + + try: + url = 'http://%s@127.0.0.1:%d/' % (auth, rpc_port) + if wallet: + url += 'wallet/' + wallet + x = Jsonrpc(url) + + v = x.json_request(method, params) + x.close() + r = json.loads(v.decode('utf-8')) + except Exception as e: + traceback.print_exc() + raise ValueError('RPC Server Error') + + if 'error' in r and r['error'] is not None: + raise ValueError('RPC error ' + str(r['error'])) + + return r['result'] + + +def callrpc_cli(bindir, datadir, chain, cmd, cli_bin='particl-cli'): + cli_bin = os.path.join(bindir, cli_bin) + + args = cli_bin + ('' if chain == 'mainnet' else ' -' + chain) + ' -datadir=' + datadir + ' ' + cmd + p = subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, shell=True) + out = p.communicate() + + if len(out[1]) > 0: + raise ValueError('RPC error ' + str(out[1])) + + r = out[0].decode('utf-8').strip() + try: + r = json.loads(r) + except Exception: + pass + return r diff --git a/bin/__init__.py b/bin/__init__.py new file mode 100644 index 0000000..9f6f3e6 --- /dev/null +++ b/bin/__init__.py @@ -0,0 +1 @@ +name = "bin" diff --git a/bin/basicswap-run.py b/bin/basicswap-run.py new file mode 120000 index 0000000..0bf5b17 --- /dev/null +++ b/bin/basicswap-run.py @@ -0,0 +1 @@ +basicswap_run.py \ No newline at end of file diff --git a/bin/basicswap_run.py b/bin/basicswap_run.py new file mode 100644 index 0000000..5aa90b8 --- /dev/null +++ b/bin/basicswap_run.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# Copyright (c) 2019 tecnovert +# Distributed under the MIT software license, see the accompanying +# file LICENSE.txt or http://www.opensource.org/licenses/mit-license.php. + +""" +Particl Atomic Swap - Proof of Concept + +Dependencies: + $ pacman -S python-pyzmq python-plyvel protobuf + +""" + +import sys +import os +import time +import json +import traceback +import signal +import subprocess + +import basicswap.config as cfg +from basicswap import __version__ +from basicswap.basicswap import BasicSwap +from basicswap.http_server import HttpThread + + +ALLOW_CORS = False +swap_client = None + + +def signal_handler(sig, frame): + print('signal %d detected, ending program.' % (sig)) + if swap_client is not None: + swap_client.stopRunning() + + +def startDaemon(node_dir, bin_dir, daemon_bin): + daemon_bin = os.path.join(bin_dir, daemon_bin) + + args = [daemon_bin, '-datadir=' + node_dir] + print('Starting node ' + daemon_bin + ' ' + '-datadir=' + node_dir) + return subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + + +def runClient(fp, dataDir, chain): + global swap_client + settings_path = os.path.join(dataDir, 'basicswap.json') + + if not os.path.exists(settings_path): + raise ValueError('Settings file not found: ' + str(settings_path)) + + with open(settings_path) as fs: + settings = json.load(fs) + + daemons = [] + + for c, v in settings['chainclients'].items(): + if v['manage_daemon'] is True: + print('Starting {} daemon'.format(c.capitalize())) + if c == 'particl': + daemons.append(startDaemon(v['datadir'], cfg.PARTICL_BINDIR, cfg.PARTICLD)) + print('Started {} {}'.format(cfg.PARTICLD, daemons[-1].pid)) + elif c == 'bitcoin': + daemons.append(startDaemon(v['datadir'], cfg.BITCOIN_BINDIR, cfg.BITCOIND)) + print('Started {} {}'.format(cfg.BITCOIND, daemons[-1].pid)) + elif c == 'litecoin': + daemons.append(startDaemon(v['datadir'], cfg.LITECOIN_BINDIR, cfg.LITECOIND)) + print('Started {} {}'.format(cfg.LITECOIND, daemons[-1].pid)) + else: + print('Unknown chain', c) + + swap_client = BasicSwap(fp, dataDir, settings, chain) + + signal.signal(signal.SIGINT, signal_handler) + signal.signal(signal.SIGTERM, signal_handler) + swap_client.start() + + threads = [] + if 'htmlhost' in settings: + swap_client.log.info('Starting server at %s:%d.' % (settings['htmlhost'], settings['htmlport'])) + allow_cors = settings['allowcors'] if 'allowcors' in settings else ALLOW_CORS + tS1 = HttpThread(fp, settings['htmlhost'], settings['htmlport'], allow_cors, swap_client) + threads.append(tS1) + tS1.start() + + try: + print('Exit with Ctrl + c.') + while swap_client.is_running: + time.sleep(0.5) + swap_client.update() + except Exception: + traceback.print_exc() + + swap_client.log.info('Stopping threads.') + for t in threads: + t.stop() + t.join() + + for d in daemons: + print('Terminating {}'.format(d.pid)) + d.terminate() + d.wait(timeout=120) + if d.stdout: + d.stdout.close() + if d.stderr: + d.stderr.close() + if d.stdin: + d.stdin.close() + + +def printVersion(): + print('Basicswap version:', __version__) + + +def printHelp(): + print('basicswap-run.py --datadir=path -testnet') + + +def main(): + data_dir = None + chain = 'mainnet' + + for v in sys.argv[1:]: + if len(v) < 2 or v[0] != '-': + print('Unknown argument', v) + continue + + s = v.split('=') + name = s[0].strip() + + for i in range(2): + if name[0] == '-': + name = name[1:] + + if name == 'v' or name == 'version': + printVersion() + return 0 + if name == 'h' or name == 'help': + printHelp() + return 0 + if name == 'testnet': + chain = 'testnet' + continue + if name == 'regtest': + chain = 'regtest' + continue + + if len(s) == 2: + if name == 'datadir': + data_dir = os.path.expanduser(s[1]) + continue + + print('Unknown argument', v) + + if data_dir is None: + data_dir = os.path.join(os.path.expanduser(os.path.join(cfg.DATADIRS)), 'particl', ('' if chain == 'mainnet' else chain), 'basicswap') + + print('data_dir:', data_dir) + if chain != 'mainnet': + print('chain:', chain) + + if not os.path.exists(data_dir): + os.makedirs(data_dir) + + with open(os.path.join(data_dir, 'basicswap.log'), 'w') as fp: + print(os.path.basename(sys.argv[0]) + ', version: ' + __version__ + '\n\n') + runClient(fp, data_dir, chain) + + print('Done.') + return swap_client.fail_code if swap_client is not None else 0 + + +if __name__ == '__main__': + main() diff --git a/bin/start_docker.bat b/bin/start_docker.bat new file mode 100644 index 0000000..e69de29 diff --git a/docker/coindata/.gitignore b/docker/coindata/.gitignore new file mode 100644 index 0000000..3ac4a36 --- /dev/null +++ b/docker/coindata/.gitignore @@ -0,0 +1,5 @@ +* +!.gitignore +!basicswap/basicswap.json +!particl/particl.conf +!litecoin/litecoin.conf diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 0000000..9b490c7 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,15 @@ +version: '3' +services: + + swapclient: + build: + context: ../ + volumes: + - ./coindata:/coindata + ports: + - "127.0.0.1:12700:12700" # Expose only to localhost + +volumes: + coindata: + driver: local + diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..2b00a9a --- /dev/null +++ b/setup.py @@ -0,0 +1,37 @@ +import setuptools +import re +import io + +__version__ = re.search( + r'__version__\s*=\s*[\'"]([^\'"]*)[\'"]', + io.open('basicswap/__init__.py', encoding='utf_8_sig').read() +).group(1) + +setuptools.setup( + name="basicswap", + version=__version__, + author="tecnovert", + author_email="hello@particl.io", + description="Particl atomic swap demo", + long_description=open("README.md").read(), + long_description_content_type="text/markdown", + url="https://github.com/tecnovert/basicswap", + packages=setuptools.find_packages(), + classifiers=[ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: Linux", + ], + install_requires=[ + "pyzmq", + "plyvel", + "protobuf", + "sqlalchemy", + ], + entry_points={ + "console_scripts": [ + "basicswap-run=bin.basicswap_run:main", + ] + }, + test_suite="tests.test_suite" +) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..84e7855 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1,11 @@ +import unittest + +import tests.test_run +import tests.test_other + + +def test_suite(): + loader = unittest.TestLoader() + suite = loader.loadTestsFromModule(tests.test_run) + suite.addTests(loader.loadTestsFromModule(tests.test_other)) + return suite diff --git a/tests/test_other.py b/tests/test_other.py new file mode 100644 index 0000000..d615c9d --- /dev/null +++ b/tests/test_other.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# Copyright (c) 2019 tecnovert +# Distributed under the MIT software license, see the accompanying +# file LICENSE.txt or http://www.opensource.org/licenses/mit-license.php. + +import unittest +from basicswap.util import ( + SerialiseNum, + DeserialiseNum, +) +from basicswap.basicswap import ( + Coins, + getExpectedSequence, + decodeSequence, + SEQUENCE_LOCK_BLOCKS, + SEQUENCE_LOCK_TIME, +) + + +def test_case(v, nb=None): + b = SerialiseNum(v) + if nb is not None: + assert(len(b) == nb) + assert(v == DeserialiseNum(b)) + + +class Test(unittest.TestCase): + def test_serialise_num(self): + test_case(0, 1) + test_case(1, 1) + test_case(16, 1) + + test_case(-1, 2) + test_case(17, 2) + + test_case(500) + test_case(-500) + test_case(4194642) + + def test_sequence(self): + time_val = 48 * 60 * 60 + encoded = getExpectedSequence(SEQUENCE_LOCK_TIME, time_val, Coins.PART) + decoded = decodeSequence(encoded) + assert(decoded >= time_val) + assert(decoded <= time_val + 512) + + time_val = 24 * 60 + encoded = getExpectedSequence(SEQUENCE_LOCK_TIME, time_val, Coins.PART) + decoded = decodeSequence(encoded) + assert(decoded >= time_val) + assert(decoded <= time_val + 512) + + blocks_val = 123 + encoded = getExpectedSequence(SEQUENCE_LOCK_BLOCKS, blocks_val, Coins.PART) + decoded = decodeSequence(encoded) + assert(decoded == blocks_val) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_run.py b/tests/test_run.py new file mode 100644 index 0000000..2ac55ad --- /dev/null +++ b/tests/test_run.py @@ -0,0 +1,536 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- + +# Copyright (c) 2019 tecnovert +# Distributed under the MIT software license, see the accompanying +# file LICENSE.txt or http://www.opensource.org/licenses/mit-license.php. + +""" +basicswap]$ python setup.py test + +Run one test: +$ python setup.py test -s tests.test_run.Test.test_04_ltc_btc + +""" + +import os +import sys +import unittest +import json +import logging +import shutil +import subprocess +import time +import signal +import threading +from urllib.request import urlopen + +from basicswap.basicswap import ( + BasicSwap, + Coins, + SwapTypes, + BidStates, + TxStates, + SEQUENCE_LOCK_BLOCKS, +) +from basicswap.util import ( + COIN, + toWIF, + callrpc_cli, + dumpje, +) +from basicswap.key import ( + ECKey, +) +from basicswap.http_server import ( + HttpThread, +) + +import basicswap.config as cfg + +logger = logging.getLogger() +logger.level = logging.DEBUG +logger.addHandler(logging.StreamHandler(sys.stdout)) + +NUM_NODES = 3 +BASE_PORT = 14792 +BASE_RPC_PORT = 19792 +BASE_ZMQ_PORT = 20792 +PREFIX_SECRET_KEY_REGTEST = 0x2e +TEST_HTML_PORT = 1800 +LTC_NODE = 3 +BTC_NODE = 4 +stop_test = False + + +def prepareOtherDir(datadir, nodeId, conf_file='litecoin.conf'): + node_dir = os.path.join(datadir, str(nodeId)) + if not os.path.exists(node_dir): + os.makedirs(node_dir) + filePath = os.path.join(node_dir, conf_file) + + with open(filePath, 'w+') as fp: + fp.write('regtest=1\n') + fp.write('[regtest]\n') + fp.write('port=' + str(BASE_PORT + nodeId) + '\n') + fp.write('rpcport=' + str(BASE_RPC_PORT + nodeId) + '\n') + + fp.write('daemon=0\n') + fp.write('printtoconsole=0\n') + fp.write('server=1\n') + fp.write('discover=0\n') + fp.write('listenonion=0\n') + fp.write('bind=127.0.0.1\n') + fp.write('findpeers=0\n') + fp.write('debug=1\n') + fp.write('debugexclude=libevent\n') + + fp.write('acceptnonstdtxn=0\n') + + +def prepareDir(datadir, nodeId, network_key, network_pubkey): + node_dir = os.path.join(datadir, str(nodeId)) + if not os.path.exists(node_dir): + os.makedirs(node_dir) + filePath = os.path.join(node_dir, 'particl.conf') + + with open(filePath, 'w+') as fp: + fp.write('regtest=1\n') + fp.write('[regtest]\n') + fp.write('port=' + str(BASE_PORT + nodeId) + '\n') + fp.write('rpcport=' + str(BASE_RPC_PORT + nodeId) + '\n') + + fp.write('daemon=0\n') + fp.write('printtoconsole=0\n') + fp.write('server=1\n') + fp.write('discover=0\n') + fp.write('listenonion=0\n') + fp.write('bind=127.0.0.1\n') + fp.write('findpeers=0\n') + fp.write('debug=1\n') + fp.write('debugexclude=libevent\n') + fp.write('zmqpubsmsg=tcp://127.0.0.1:' + str(BASE_ZMQ_PORT + nodeId) + '\n') + + fp.write('acceptnonstdtxn=0\n') + fp.write('minstakeinterval=5\n') + + for i in range(0, NUM_NODES): + if nodeId == i: + continue + fp.write('addnode=127.0.0.1:%d\n' % (BASE_PORT + i)) + + if nodeId < 2: + fp.write('spentindex=1\n') + fp.write('txindex=1\n') + + basicswap_dir = os.path.join(datadir, str(nodeId), 'basicswap') + if not os.path.exists(basicswap_dir): + os.makedirs(basicswap_dir) + + ltcdatadir = os.path.join(datadir, str(LTC_NODE)) + btcdatadir = os.path.join(datadir, str(BTC_NODE)) + settings_path = os.path.join(basicswap_dir, 'basicswap.json') + settings = { + 'zmqhost': 'tcp://127.0.0.1', + 'zmqport': BASE_ZMQ_PORT + nodeId, + 'htmlhost': 'localhost', + 'htmlport': 12700 + nodeId, + 'network_key': network_key, + 'network_pubkey': network_pubkey, + 'chainclients': { + 'particl': { + 'connection_type': 'rpc', + 'manage_daemon': False, + 'rpcport': BASE_RPC_PORT + nodeId, + 'datadir': node_dir, + 'bindir': cfg.PARTICL_BINDIR, + 'blocks_confirmed': 2, # Faster testing + }, + 'litecoin': { + 'connection_type': 'rpc', + 'manage_daemon': False, + 'rpcport': BASE_RPC_PORT + LTC_NODE, + 'datadir': ltcdatadir, + 'bindir': cfg.LITECOIN_BINDIR, + # 'use_segwit': True, + }, + 'bitcoin': { + 'connection_type': 'rpc', + 'manage_daemon': False, + 'rpcport': BASE_RPC_PORT + BTC_NODE, + 'datadir': btcdatadir, + 'bindir': cfg.BITCOIN_BINDIR, + 'use_segwit': True, + } + }, + 'check_progress_seconds': 2, + 'check_watched_seconds': 4, + 'check_expired_seconds': 60 + } + with open(settings_path, 'w') as fp: + json.dump(settings, fp, indent=4) + + +def startDaemon(nodeId, bin_dir=cfg.PARTICL_BINDIR, daemon_bin=cfg.PARTICLD): + node_dir = os.path.join(cfg.DATADIRS, str(nodeId)) + daemon_bin = os.path.join(bin_dir, daemon_bin) + + args = [daemon_bin, '-datadir=' + node_dir] + logging.info('Starting node ' + str(nodeId) + ' ' + daemon_bin + ' ' + '-datadir=' + node_dir) + return subprocess.Popen(args, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + + +def partRpc(cmd, node_id=0): + return callrpc_cli(cfg.PARTICL_BINDIR, os.path.join(cfg.DATADIRS, str(node_id)), 'regtest', cmd, cfg.PARTICL_CLI) + + +def btcRpc(cmd): + return callrpc_cli(cfg.BITCOIN_BINDIR, os.path.join(cfg.DATADIRS, str(BTC_NODE)), 'regtest', cmd, cfg.BITCOIN_CLI) + + +def ltcRpc(cmd): + return callrpc_cli(cfg.LITECOIN_BINDIR, os.path.join(cfg.DATADIRS, str(LTC_NODE)), 'regtest', cmd, cfg.LITECOIN_CLI) + + +def signal_handler(sig, frame): + global stop_test + print('signal {} detected.'.format(sig)) + stop_test = True + + +def run_loop(self): + while not stop_test: + time.sleep(1) + for c in self.swap_clients: + c.update() + ltcRpc('generatetoaddress 1 {}'.format(self.ltc_addr)) + btcRpc('generatetoaddress 1 {}'.format(self.btc_addr)) + + +class Test(unittest.TestCase): + + @classmethod + def setUpClass(cls): + super(Test, cls).setUpClass() + + eckey = ECKey() + eckey.generate() + cls.network_key = toWIF(PREFIX_SECRET_KEY_REGTEST, eckey.get_bytes()) + cls.network_pubkey = eckey.get_pubkey().get_bytes().hex() + + if os.path.isdir(cfg.DATADIRS): + logging.info('Removing ' + cfg.DATADIRS) + shutil.rmtree(cfg.DATADIRS) + + for i in range(NUM_NODES): + prepareDir(cfg.DATADIRS, i, cls.network_key, cls.network_pubkey) + + prepareOtherDir(cfg.DATADIRS, LTC_NODE) + prepareOtherDir(cfg.DATADIRS, BTC_NODE, 'bitcoin.conf') + + cls.daemons = [] + cls.swap_clients = [] + + cls.daemons.append(startDaemon(BTC_NODE, cfg.BITCOIN_BINDIR, cfg.BITCOIND)) + logging.info('Started %s %d', cfg.BITCOIND, cls.daemons[-1].pid) + cls.daemons.append(startDaemon(LTC_NODE, cfg.LITECOIN_BINDIR, cfg.LITECOIND)) + logging.info('Started %s %d', cfg.LITECOIND, cls.daemons[-1].pid) + + for i in range(NUM_NODES): + cls.daemons.append(startDaemon(i)) + logging.info('Started %s %d', cfg.PARTICLD, cls.daemons[-1].pid) + time.sleep(1) + for i in range(NUM_NODES): + basicswap_dir = os.path.join(os.path.join(cfg.DATADIRS, str(i)), 'basicswap') + settings_path = os.path.join(basicswap_dir, 'basicswap.json') + with open(settings_path) as fs: + settings = json.load(fs) + fp = open(os.path.join(basicswap_dir, 'basicswap.log'), 'w') + cls.swap_clients.append(BasicSwap(fp, basicswap_dir, settings, 'regtest', log_name='BasicSwap{}'.format(i))) + cls.swap_clients[-1].start() + cls.swap_clients[0].callrpc('extkeyimportmaster', ['abandon baby cabbage dad eager fabric gadget habit ice kangaroo lab absorb']) + cls.swap_clients[1].callrpc('extkeyimportmaster', ['pact mammal barrel matrix local final lecture chunk wasp survey bid various book strong spread fall ozone daring like topple door fatigue limb olympic', '', 'true']) + cls.swap_clients[1].callrpc('getnewextaddress', ['lblExtTest']) + cls.swap_clients[1].callrpc('rescanblockchain') + + num_blocks = 500 + logging.info('Mining %d litecoin blocks', num_blocks) + cls.ltc_addr = ltcRpc('getnewaddress mining_addr legacy') + ltcRpc('generatetoaddress {} {}'.format(num_blocks, cls.ltc_addr)) + + ro = ltcRpc('getblockchaininfo') + assert(ro['bip9_softforks']['csv']['status'] == 'active') + assert(ro['bip9_softforks']['segwit']['status'] == 'active') + + cls.btc_addr = btcRpc('getnewaddress mining_addr bech32') + logging.info('Mining %d bitcoin blocks to %s', num_blocks, cls.btc_addr) + btcRpc('generatetoaddress {} {}'.format(num_blocks, cls.btc_addr)) + + ro = btcRpc('getblockchaininfo') + assert(ro['bip9_softforks']['csv']['status'] == 'active') + assert(ro['bip9_softforks']['segwit']['status'] == 'active') + + ro = ltcRpc('getwalletinfo') + print('ltcRpc', ro) + + cls.http_threads = [] + host = '0.0.0.0' # All interfaces (docker) + for i in range(3): + t = HttpThread(cls.swap_clients[i].fp, host, TEST_HTML_PORT + i, False, cls.swap_clients[i]) + cls.http_threads.append(t) + t.start() + + signal.signal(signal.SIGINT, signal_handler) + cls.update_thread = threading.Thread(target=run_loop, args=(cls,)) + cls.update_thread.start() + + @classmethod + def tearDownClass(cls): + global stop_test + logging.info('Finalising') + stop_test = True + cls.update_thread.join() + for t in cls.http_threads: + t.stop() + t.join() + for c in cls.swap_clients: + c.fp.close() + for d in cls.daemons: + logging.info('Terminating %d', d.pid) + d.terminate() + d.wait(timeout=10) + if d.stdout: + d.stdout.close() + if d.stderr: + d.stderr.close() + if d.stdin: + d.stdin.close() + + super(Test, cls).tearDownClass() + + def wait_for_offer(self, swap_client, offer_id): + logging.info('wait_for_offer %s', offer_id.hex()) + for i in range(20): + time.sleep(1) + offers = swap_client.listOffers() + for offer in offers: + if offer.offer_id == offer_id: + return + raise ValueError('wait_for_offer timed out.') + + def wait_for_bid(self, swap_client, bid_id): + logging.info('wait_for_bid %s', bid_id.hex()) + for i in range(20): + time.sleep(1) + bids = swap_client.listBids() + for bid in bids: + if bid.bid_id == bid_id and bid.was_received: + return + raise ValueError('wait_for_bid timed out.') + + def wait_for_in_progress(self, swap_client, bid_id, sent=False): + logging.info('wait_for_in_progress %s', bid_id.hex()) + for i in range(20): + time.sleep(1) + swaps = swap_client.listSwapsInProgress() + for b in swaps: + if b[0] == bid_id: + return + raise ValueError('wait_for_in_progress timed out.') + + def wait_for_bid_state(self, swap_client, bid_id, state, sent=False, seconds_for=30): + logging.info('wait_for_bid_state %s %s', bid_id.hex(), str(state)) + for i in range(seconds_for): + time.sleep(1) + bid = swap_client.getBid(bid_id) + if bid.state >= state: + return + raise ValueError('wait_for_bid_state timed out.') + + def wait_for_bid_tx_state(self, swap_client, bid_id, initiate_state, participate_state, seconds_for=30): + logging.info('wait_for_bid_tx_state %s %s %s', bid_id.hex(), str(initiate_state), str(participate_state)) + for i in range(seconds_for): + time.sleep(1) + bid = swap_client.getBid(bid_id) + if (initiate_state is None or bid.initiate_txn_state == initiate_state) \ + and (participate_state is None or bid.participate_txn_state == participate_state): + return + raise ValueError('wait_for_bid_tx_state timed out.') + + def test_01_verifyrawtransaction(self): + txn = '0200000001eb6e5c4ebba4efa32f40c7314cad456a64008e91ee30b2dd0235ab9bb67fbdbb01000000ee47304402200956933242dde94f6cf8f195a470f8d02aef21ec5c9b66c5d3871594bdb74c9d02201d7e1b440de8f4da672d689f9e37e98815fb63dbc1706353290887eb6e8f7235012103dc1b24feb32841bc2f4375da91fa97834e5983668c2a39a6b7eadb60e7033f9d205a803b28fe2f86c17db91fa99d7ed2598f79b5677ffe869de2e478c0d1c02cc7514c606382012088a8201fe90717abb84b481c2a59112414ae56ec8acc72273642ca26cc7a5812fdc8f68876a914225fbfa4cb725b75e511810ac4d6f74069bdded26703520140b27576a914207eb66b2fd6ed9924d6217efc7fa7b38dfabe666888acffffffff01e0167118020000001976a9140044e188928710cecba8311f1cf412135b98145c88ac00000000' + prevout = { + 'txid': 'bbbd7fb69bab3502ddb230ee918e00646a45ad4c31c7402fa3efa4bb4e5c6eeb', + 'vout': 1, + 'scriptPubKey': 'a9143d37191e8b864222d14952a14c85504677a0581d87', + 'redeemScript': '6382012088a8201fe90717abb84b481c2a59112414ae56ec8acc72273642ca26cc7a5812fdc8f68876a914225fbfa4cb725b75e511810ac4d6f74069bdded26703520140b27576a914207eb66b2fd6ed9924d6217efc7fa7b38dfabe666888ac', + 'amount': 1.0} + ro = partRpc('verifyrawtransaction {} "{}"'.format(txn, dumpje([prevout, ]))) + assert(ro['inputs_valid'] is False) + assert(ro['validscripts'] == 1) + + prevout['amount'] = 100.0 + ro = partRpc('verifyrawtransaction {} "{}"'.format(txn, dumpje([prevout, ]))) + assert(ro['inputs_valid'] is True) + assert(ro['validscripts'] == 1) + + txn = 'a000000000000128e8ba6a28673f2ebb5fd983b27a791fd1888447a47638b3cd8bfdd3f54a6f1e0100000000a90040000101e0c69a3b000000001976a9146c0f1ea47ca2bf84ed87bf3aa284e18748051f5788ac04473044022026b01f3a90e46883949404141467b741cd871722a4aaae8ddc8c4d6ab6fb1c77022047a2f3be2dcbe4c51837d2d5e0329aaa8a13a8186b03186b127cc51185e4f3ab012103dc1b24feb32841bc2f4375da91fa97834e5983668c2a39a6b7eadb60e7033f9d0100606382012088a8201fe90717abb84b481c2a59112414ae56ec8acc72273642ca26cc7a5812fdc8f68876a914207eb66b2fd6ed9924d6217efc7fa7b38dfabe666703a90040b27576a914225fbfa4cb725b75e511810ac4d6f74069bdded26888ac' + prevout = { + 'txid': '1e6f4af5d3fd8bcdb33876a4478488d11f797ab283d95fbb2e3f67286abae828', + 'vout': 1, + 'scriptPubKey': 'a914129aee070317bbbd57062288849e85cf57d15c2687', + 'redeemScript': '6382012088a8201fe90717abb84b481c2a59112414ae56ec8acc72273642ca26cc7a5812fdc8f68876a914207eb66b2fd6ed9924d6217efc7fa7b38dfabe666703a90040b27576a914225fbfa4cb725b75e511810ac4d6f74069bdded26888ac', + 'amount': 1.0} + ro = partRpc('verifyrawtransaction {} "{}"'.format(txn, dumpje([prevout, ]))) + assert(ro['inputs_valid'] is False) + assert(ro['validscripts'] == 0) # Amount covered by signature + + prevout['amount'] = 90.0 + ro = partRpc('verifyrawtransaction {} "{}"'.format(txn, dumpje([prevout, ]))) + assert(ro['inputs_valid'] is True) + assert(ro['validscripts'] == 1) + + def test_02_part_ltc(self): + swap_clients = self.swap_clients + + logging.info('---------- Test PART to LTC') + offer_id = swap_clients[0].postOffer(Coins.PART, Coins.LTC, 100 * COIN, 0.1 * COIN, 100 * COIN, SwapTypes.SELLER_FIRST) + + self.wait_for_offer(swap_clients[1], offer_id) + offers = swap_clients[1].listOffers() + assert(len(offers) == 1) + for offer in offers: + if offer.offer_id == offer_id: + bid_id = swap_clients[1].postBid(offer_id, offer.amount_from) + + self.wait_for_bid(swap_clients[0], bid_id) + + swap_clients[0].acceptBid(bid_id) + + self.wait_for_in_progress(swap_clients[1], bid_id, sent=True) + + self.wait_for_bid_state(swap_clients[0], bid_id, BidStates.SWAP_COMPLETED, seconds_for=60) + self.wait_for_bid_state(swap_clients[1], bid_id, BidStates.SWAP_COMPLETED, sent=True, seconds_for=60) + + js_0 = json.loads(urlopen('http://localhost:1800/json').read()) + js_1 = json.loads(urlopen('http://localhost:1801/json').read()) + assert(js_0['num_swapping'] == 0 and js_0['num_watched_outputs'] == 0) + assert(js_1['num_swapping'] == 0 and js_1['num_watched_outputs'] == 0) + + def test_03_ltc_part(self): + swap_clients = self.swap_clients + + logging.info('---------- Test LTC to PART') + offer_id = swap_clients[1].postOffer(Coins.LTC, Coins.PART, 10 * COIN, 9.0 * COIN, 10 * COIN, SwapTypes.SELLER_FIRST) + + self.wait_for_offer(swap_clients[0], offer_id) + offers = swap_clients[0].listOffers() + for offer in offers: + if offer.offer_id == offer_id: + bid_id = swap_clients[0].postBid(offer_id, offer.amount_from) + + self.wait_for_bid(swap_clients[1], bid_id) + swap_clients[1].acceptBid(bid_id) + + self.wait_for_in_progress(swap_clients[0], bid_id, sent=True) + + self.wait_for_bid_state(swap_clients[0], bid_id, BidStates.SWAP_COMPLETED, sent=True, seconds_for=60) + self.wait_for_bid_state(swap_clients[1], bid_id, BidStates.SWAP_COMPLETED, seconds_for=60) + + js_0 = json.loads(urlopen('http://localhost:1800/json').read()) + js_1 = json.loads(urlopen('http://localhost:1801/json').read()) + assert(js_0['num_swapping'] == 0 and js_0['num_watched_outputs'] == 0) + assert(js_1['num_swapping'] == 0 and js_1['num_watched_outputs'] == 0) + + def test_04_ltc_btc(self): + swap_clients = self.swap_clients + + logging.info('---------- Test LTC to BTC') + offer_id = swap_clients[0].postOffer(Coins.LTC, Coins.BTC, 10 * COIN, 0.1 * COIN, 10 * COIN, SwapTypes.SELLER_FIRST) + + self.wait_for_offer(swap_clients[1], offer_id) + offers = swap_clients[1].listOffers() + for offer in offers: + if offer.offer_id == offer_id: + bid_id = swap_clients[1].postBid(offer_id, offer.amount_from) + + self.wait_for_bid(swap_clients[0], bid_id) + swap_clients[0].acceptBid(bid_id) + + self.wait_for_in_progress(swap_clients[1], bid_id, sent=True) + + self.wait_for_bid_state(swap_clients[0], bid_id, BidStates.SWAP_COMPLETED, seconds_for=60) + self.wait_for_bid_state(swap_clients[1], bid_id, BidStates.SWAP_COMPLETED, sent=True, seconds_for=60) + + js_0bid = json.loads(urlopen('http://localhost:1800/json/bids/{}'.format(bid_id.hex())).read()) + + js_0 = json.loads(urlopen('http://localhost:1800/json').read()) + js_1 = json.loads(urlopen('http://localhost:1801/json').read()) + + assert(js_0['num_swapping'] == 0 and js_0['num_watched_outputs'] == 0) + assert(js_1['num_swapping'] == 0 and js_1['num_watched_outputs'] == 0) + + def test_05_refund(self): + # Seller submits initiate txn, buyer doesn't respond + swap_clients = self.swap_clients + + logging.info('---------- Test refund, LTC to BTC') + offer_id = swap_clients[0].postOffer(Coins.LTC, Coins.BTC, 10 * COIN, 0.1 * COIN, 10 * COIN, SwapTypes.SELLER_FIRST, + SEQUENCE_LOCK_BLOCKS, 10) + + self.wait_for_offer(swap_clients[1], offer_id) + offers = swap_clients[1].listOffers() + for offer in offers: + if offer.offer_id == offer_id: + bid_id = swap_clients[1].postBid(offer_id, offer.amount_from) + + self.wait_for_bid(swap_clients[0], bid_id) + swap_clients[1].abandonBid(bid_id) + swap_clients[0].acceptBid(bid_id) + + self.wait_for_bid_state(swap_clients[0], bid_id, BidStates.SWAP_COMPLETED, seconds_for=60) + self.wait_for_bid_state(swap_clients[1], bid_id, BidStates.BID_ABANDONED, sent=True, seconds_for=60) + + js_0 = json.loads(urlopen('http://localhost:1800/json').read()) + js_1 = json.loads(urlopen('http://localhost:1801/json').read()) + assert(js_0['num_swapping'] == 0 and js_0['num_watched_outputs'] == 0) + assert(js_1['num_swapping'] == 0 and js_1['num_watched_outputs'] == 0) + + def test_06_self_bid(self): + swap_clients = self.swap_clients + + logging.info('---------- Test same client, BTC to LTC') + + js_0_before = json.loads(urlopen('http://localhost:1800/json').read()) + + offer_id = swap_clients[0].postOffer(Coins.LTC, Coins.BTC, 10 * COIN, 10 * COIN, 10 * COIN, SwapTypes.SELLER_FIRST) + + self.wait_for_offer(swap_clients[0], offer_id) + offers = swap_clients[0].listOffers() + for offer in offers: + if offer.offer_id == offer_id: + bid_id = swap_clients[0].postBid(offer_id, offer.amount_from) + + self.wait_for_bid(swap_clients[0], bid_id) + swap_clients[0].acceptBid(bid_id) + + self.wait_for_bid_tx_state(swap_clients[0], bid_id, TxStates.TX_REDEEMED, TxStates.TX_REDEEMED, seconds_for=60) + self.wait_for_bid_state(swap_clients[0], bid_id, BidStates.SWAP_COMPLETED, seconds_for=60) + + js_0 = json.loads(urlopen('http://localhost:1800/json').read()) + assert(js_0['num_swapping'] == 0 and js_0['num_watched_outputs'] == 0) + assert(js_0['num_recv_bids'] == js_0_before['num_recv_bids'] + 1 and js_0['num_sent_bids'] == js_0_before['num_sent_bids'] + 1) + + def pass_99_delay(self): + global stop_test + logging.info('Delay') + for i in range(60 * 5): + if stop_test: + break + time.sleep(1) + print('delay', i) + stop_test = True + + +if __name__ == '__main__': + unittest.main()