diff --git a/README.md b/README.md new file mode 100644 index 0000000..62f69b1 --- /dev/null +++ b/README.md @@ -0,0 +1,30 @@ +#TokenDex + +A plugin for ElectronCash-SLP that allows you to trade SLP tokens with BCH (Token -> BCH and BCH -> Token) in a noncustodial manner. Everything including the order book is on the blockchain. There are no centralized servers. + +##Installation +1. Download the latest release. +2. From the ElectronCash-SLP application menu select `Tools -> Installed Plugins`, then click `Add plugin` and then select the downloaded file from step 1. +3. The plugin will then be installed and enabled. + +Now you should see a new tab in your wallet window. + +##Brief Usage Overview + +1. Select one of the tokens available in your wallet. +2. You can place a sell or buy order using the "Place Order" form. +3. You can take any of the orders from their specified sections. +4. Taking sell orders are instant but for taking the buy orders, the other party have to accept your take signal. +5. If someone signals one of your buy orders, a button will appear next to order in the "Your Orders" section. + +## Support +If you had any question regarding the plugin, please feel free to drop a message in [simpleledger Telegram group](https://t.me/simpleledger). + +## Support The Developer +If you wish to donate to the developer, you can use the following address: `bitcoincash:qz2q3c4svltz5x047j87g8a7gkrh03xmc5mh0lvz0j` + +##Special Thanks +Very special thanks to those who helped fund the project. + +##License +MIT License \ No newline at end of file diff --git a/TokenDex/.gitignore b/TokenDex/.gitignore new file mode 100644 index 0000000..9fe17bc --- /dev/null +++ b/TokenDex/.gitignore @@ -0,0 +1,129 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ \ No newline at end of file diff --git a/TokenDex/LICENSE b/TokenDex/LICENSE new file mode 100644 index 0000000..13be9db --- /dev/null +++ b/TokenDex/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 OPReturn + +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. \ No newline at end of file diff --git a/TokenDex/__init__.py b/TokenDex/__init__.py new file mode 100644 index 0000000..f3107d5 --- /dev/null +++ b/TokenDex/__init__.py @@ -0,0 +1,3 @@ +fullname = "TokenDex" +available_for = ["qt"] +description = "SLP on-chain noncustodial token swap" diff --git a/TokenDex/bfp.py b/TokenDex/bfp.py new file mode 100644 index 0000000..469dad7 --- /dev/null +++ b/TokenDex/bfp.py @@ -0,0 +1,114 @@ +import hashlib + +from electroncash import bitcoinfiles +from electroncash.transaction import Transaction + + +def upload_file(wallet, file_bytes, config={}, password=None): + file_tx_id = None + tx_batch = [] + + file_size = len(file_bytes) + assert file_size <= 10522 + + metadata = dict() + metadata['filename'] = 'data' + metadata['fileext'] = 'json' + metadata['filesize'] = file_size + metadata['file_sha256'] = hashlib.sha256(file_bytes).hexdigest() + metadata['prev_file_sha256'] = None + metadata['uri'] = None + + cost = bitcoinfiles.calculateUploadCost(file_size, metadata) + file_receiver_address = wallet.get_addresses()[0] # todo change this + + # TODO, guard tokens during this transaction? + ##################################################################################### + + file_220_chunks = [] + for i in range(1, (len(file_bytes) // 220) + 2): + file_220_chunks.append(file_bytes[(i-1)*220:i*220]) + + funding_tx = bitcoinfiles.getFundingTxn(wallet, file_receiver_address, cost, config) + wallet.sign_transaction(funding_tx, password) + tx_batch.append(funding_tx) + + prev_tx = funding_tx + for i in range(len(file_220_chunks)): + tx, is_metadata_tx = bitcoinfiles.getUploadTxn( + wallet, prev_tx=prev_tx, chunk_index=i, chunk_count=len(file_220_chunks), + chunk_data=file_220_chunks[i], config=config, metadata=metadata, file_receiver=file_receiver_address + ) + wallet.sign_transaction(tx, password) + tx_batch.append(tx) + prev_tx = tx + + if not is_metadata_tx: # last chunk didn't fit into the metadata tx + tx, is_metadata_tx = bitcoinfiles.getUploadTxn( + wallet, prev_tx=prev_tx, chunk_index=i+1, chunk_count=len(file_220_chunks), + chunk_data=b'', config=config, metadata=metadata, file_receiver=file_receiver_address + ) + wallet.sign_transaction(tx, password) + tx_batch.append(tx) + if is_metadata_tx: + file_tx_id = tx.txid() + + for tx in tx_batch: + status, tx_id = wallet.network.broadcast_transaction(tx) + assert status + return file_tx_id + + +def download_file(wallet, tx_id): + network = wallet.network + + status, raw_metadata_tx = network.get_raw_tx_for_txid(tx_id) + assert status + metadata_tx = Transaction(raw_metadata_tx) + + bitcoin_files_metadata_msg = bitcoinfiles.BfpMessage.parseBfpScriptOutput(metadata_tx.outputs()[0][1]) + + chunk_count = bitcoin_files_metadata_msg.op_return_fields['chunk_count'] + chunk_data = bitcoin_files_metadata_msg.op_return_fields['chunk_data'] + chunk_data_is_empty = chunk_data == b'' + + downloaded_transactions = [] + assert chunk_count != 0 + + def get_tx_chunks(tx_id, index): + status, raw_tx = network.get_raw_tx_for_txid(tx_id) + assert status is True + tx = Transaction(raw_tx) + try: + data = bitcoinfiles.parseOpreturnToChunks( + tx.outputs()[0][1].to_script(), allow_op_0=False, allow_op_number=False + ) + except bitcoinfiles.BfpOpreturnError: # It's the funding tx probably + return + downloaded_transactions.append( + {'tx_id': metadata_tx.txid(), + 'data': data[0] + } + ) + index += 1 + if index <= chunk_count - 1: # TODO removed <= to < + get_tx_chunks(tx.inputs()[0]['prevout_hash'], index) + + if chunk_count == 1: + if not chunk_data_is_empty: + downloaded_transactions.append({'tx_id': metadata_tx.txid(), 'data': chunk_data}) + # DONE! FINISHED! + if chunk_count > 1 or (chunk_count == 1 and chunk_data_is_empty): + if not chunk_data_is_empty: + downloaded_transactions.append({'tx_id': metadata_tx.txid(), 'data': chunk_data}) + index = 0 + get_tx_chunks(metadata_tx.inputs()[0]['prevout_hash'], index) + + f = b'' + downloaded_transactions.reverse() + for element in downloaded_transactions: + f += element['data'] + assert hashlib.sha256(f).hexdigest() == bitcoin_files_metadata_msg.op_return_fields['file_sha256'].hex() + return f + + diff --git a/TokenDex/dex.py b/TokenDex/dex.py new file mode 100644 index 0000000..bade5f5 --- /dev/null +++ b/TokenDex/dex.py @@ -0,0 +1,174 @@ +import json +from decimal import Decimal as PyDecimal + +from electroncash.transaction import Transaction + +from . import transaction, order, bfp, order_book, validation + + +class Dex: + + def __init__(self, wallet, token_hex, config={}, password=None): + self.wallet = wallet + self.token_hex = token_hex + self.token_decimals = self.wallet.token_types[self.token_hex]['decimals'] + self.config = config + self.password = password + + # TODO, freeze coins + def place_sell_order(self, slp_coin, slp_amount_to_sell, bch_amount, rate, min_chunk): + amount_bch_to_receive = slp_coin['token_value'] / 10**self.token_decimals * rate + assert amount_bch_to_receive == bch_amount + assert slp_coin['token_value'] == slp_amount_to_sell + partial_slp_tx = transaction.create_partial_slp_tx(self.wallet, slp_coin, bch_amount, self.password) + + sell_order_op_return = order.build_sell_order_op_return( + self.wallet, self.token_hex, slp_amount_to_sell, rate, min_chunk, + partial_slp_tx.raw, partial_slp_tx.inputs(), self.config, self.password + ) + sell_order_tx = transaction.create_signal_tx(self.wallet, sell_order_op_return, self.config) + self.wallet.sign_transaction(sell_order_tx, password=self.password) + + success, sell_order_tx_id = self.wallet.network.broadcast_transaction(sell_order_tx) + + if success: + self.wallet.set_frozen_coin_state([slp_coin], True) + slp_coin['address'] = slp_coin['address'].to_slpaddr() + order_dict = { + 'order_type': 'sell', + 'token_id': self.token_hex, + 'amount_to_sell': slp_amount_to_sell, + 'rate': rate, + 'min_chunk': min_chunk, + 'coin': slp_coin, + 'order_id': sell_order_tx_id + } + user_orders_dict = self.wallet.storage.get('user_dex_orders', {}) + user_orders_for_token = user_orders_dict.get(self.token_hex, []) + user_orders_for_token.append(order_dict) + user_orders_dict[self.token_hex] = user_orders_for_token + self.wallet.storage.put('user_dex_orders', user_orders_dict) + self.wallet.storage.write() + else: + print(sell_order_tx_id) + return success + + def place_buy_order(self, bch_order_coin, amount, rate, min_chunk): + self.wallet.set_frozen_coin_state([bch_order_coin], True) + buy_order_op_return = order.build_buy_order_op_return( + self.wallet, self.token_hex, amount, rate, min_chunk, bch_order_coin, self.config, self.password + ) + buy_order_tx = transaction.create_signal_tx(self.wallet, buy_order_op_return, self.config) + self.wallet.sign_transaction(buy_order_tx, password=self.password) + + success, buy_order_tx_id = self.wallet.network.broadcast_transaction(buy_order_tx) + + if success: + bch_order_coin['address'] = bch_order_coin['address'].to_cashaddr() + order_dict = { + 'order_type': 'buy', + 'token_id': self.token_hex, + 'amount_to_buy': amount, + 'rate': rate, + 'min_chunk': min_chunk, + 'coin': bch_order_coin, + 'order_id': buy_order_tx_id + } + user_orders_dict = self.wallet.storage.get('user_dex_orders', {}) + user_orders_for_token = user_orders_dict.get(self.token_hex, []) + user_orders_for_token.append(order_dict) + user_orders_dict[self.token_hex] = user_orders_for_token + self.wallet.storage.put('user_dex_orders', user_orders_dict) + self.wallet.storage.write() + else: + self.wallet.set_frozen_coin_state([bch_order_coin], False) + print(buy_order_tx_id) + return success + + def take_order(self, order_txid, mandatory_coin=None): + success, raw_order_tx = self.wallet.network.get_raw_tx_for_txid(order_txid) + print(success, raw_order_tx) + if success: + order_tx = Transaction(raw_order_tx) + order_op_return = order_tx.get_outputs()[0][0] + parsed_order_data = order.parse_order_op_return(order_op_return.to_script()) + order_type = parsed_order_data['order_type'] + amount_bch_to_receive = parsed_order_data['amount'] / PyDecimal(10**self.token_decimals) * parsed_order_data['rate'] + assert self.token_hex == parsed_order_data['token_hex'] + # TODO assert amount and chunks match + # TODO assert amount_bch_to_receive is OK + # if all passed, go on + + proof_of_reserve_data = json.loads(bfp.download_file(self.wallet, parsed_order_data['proof_of_reserve_tx'])) + + if order_type.lower() == 'sell' or order_type.lower() == 'take': + partial_tx_hex = proof_of_reserve_data['partial_tx'] + # verify them and validate tokens + partial_tx = Transaction(partial_tx_hex) + assert amount_bch_to_receive == partial_tx.output_value() + utxos = partial_tx.inputs() + assert len(utxos) == 1 + utxo = utxos[0] + input_is_valid = validation.validate_transaction( + self.wallet, utxo['prevout_hash'], self.token_hex, + utxo['prevout_n'], parsed_order_data['amount'] + ) + if input_is_valid: + take_order_tx = transaction.complete_partial_slp_tx( + self.wallet, partial_tx_hex, amount_bch_to_receive, + parsed_order_data['amount'], self.token_hex, mandatory_coin=mandatory_coin, config=self.config + ) + else: + print('Order contained invalid SLP tokens') + else: # order_type == buy + slp_coin = transaction.generate_slp_utxo_of_specific_size( + self.wallet, self.token_hex, utxo_size=parsed_order_data['amount'], config=self.config, password=self.password + ) + assert slp_coin + partial_slp_tx = transaction.create_partial_slp_tx(self.wallet, slp_coin, amount_bch_to_receive, + self.password) + take_order_op_return = order.build_take_order_op_return( + self.wallet, parsed_order_data['token_hex'], order_txid, parsed_order_data['amount'], partial_slp_tx.raw, self.config, self.password + ) + take_order_tx = transaction.create_signal_tx(self.wallet, take_order_op_return, self.config) + self.wallet.sign_transaction(take_order_tx, password=self.password, anyonecanpay=False) + + success, take_order_tx_id = self.wallet.network.broadcast_transaction(take_order_tx) + + if success: + if order_type.lower() == 'buy': + self.wallet.set_frozen_coin_state([slp_coin], True) + order_dict = { + 'order_type': 'take', + 'token_id': self.token_hex, + 'rate': parsed_order_data['rate'], + 'min_chunk': parsed_order_data['min_chunk'], + 'order_id': take_order_tx_id, + 'address': slp_coin['address'].to_slpaddr(), + 'coin': slp_coin, + 'amount_to_sell': slp_coin['token_value'] + } + user_orders_dict = self.wallet.storage.get('user_dex_orders', {}) + user_orders_for_token = user_orders_dict.get(self.token_hex, []) + user_orders_for_token.append(order_dict) + user_orders_dict[self.token_hex] = user_orders_for_token + self.wallet.storage.put('user_dex_orders', user_orders_dict) + self.wallet.storage.write() + else: + print(take_order_tx_id) + + return success + + def get_blockchain_sell_orders(self, from_block=None): + return order_book.get_blockchain_sell_orders(self.token_hex) + + def get_blockchain_buy_orders(self, from_block=None): + return order_book.get_blockchain_buy_orders(self.token_hex) + + def get_blockchain_take_orders(self, order_hexes, from_block=None): + return order_book.get_blockchain_take_orders(order_hexes) + + def get_utxo_info_batch(self, utxo_list, callback): + network = self.wallet.network + order_book.get_utxo_info_batch(network, utxo_list, callback) + diff --git a/TokenDex/order.py b/TokenDex/order.py new file mode 100644 index 0000000..9ae4118 --- /dev/null +++ b/TokenDex/order.py @@ -0,0 +1,149 @@ +import json +import time + +from electroncash import slp + +from . import proof_of_reserve, bfp + + +LOCAD_ID = b'DEX\x00' + + +def build_buy_order_op_return( + wallet, token_hex, amount_to_buy: int, rate: int, min_chunk: int, bch_order_coin, config={}, password=None +): + chunks = [LOCAD_ID, b'BUY'] + + token_id = bytes.fromhex(token_hex) + assert len(token_id) == 32 + chunks.append(token_id) + + amount_to_buy_bytes = int(amount_to_buy).to_bytes(8, 'big') + chunks.append(amount_to_buy_bytes) + + rate_bytes = int(rate).to_bytes(8, 'big') + chunks.append(rate_bytes) + + min_chunk_bytes = int(min_chunk).to_bytes(8, 'big') + chunks.append(min_chunk_bytes) + + chunks.append(bytes.fromhex(bch_order_coin['prevout_hash'])) + chunks.append(int(bch_order_coin['prevout_n']).to_bytes(8, 'big')) + + bch_receive_address = bch_order_coin['address'].to_cashaddr() + proof = proof_of_reserve.generate_proof_of_reserve(wallet, [bch_order_coin], password) + metadata = { + 'order_type': 'buy', + 'token_id': token_hex, + 'amount_to_buy': amount_to_buy, + 'rate': rate, + 'min_chunk': min_chunk, + 'bch_receive_address': bch_receive_address, + 'proof_of_reserve': proof + } + + file_address = bytes.fromhex(bfp.upload_file( + wallet, json.dumps(metadata).encode(), config=config, password=password + )) + chunks.append(file_address) + + time.sleep(5) + + return slp.chunksToOpreturnOutput(chunks) + + +def build_sell_order_op_return( + wallet, token_hex, amount_to_sell: int, rate: int, min_chunk: int, + partial_tx: str, inputs, config={}, password=None +): + chunks = [LOCAD_ID, b'SELL'] + + token_id = bytes.fromhex(token_hex) + assert len(token_id) == 32 + assert len(inputs) == 1 # multiple inputs are not supported for now + chunks.append(token_id) + + amount_to_sell_bytes = int(amount_to_sell).to_bytes(8, 'big') + chunks.append(amount_to_sell_bytes) + + rate_bytes = int(rate).to_bytes(8, 'big') + chunks.append(rate_bytes) + + min_chunk_bytes = int(min_chunk).to_bytes(8, 'big') + chunks.append(min_chunk_bytes) + + proof = proof_of_reserve.generate_proof_of_reserve(wallet, inputs, password) + chunks.append(bytes.fromhex(inputs[0]['prevout_hash'])) + chunks.append(int(inputs[0]['prevout_n']).to_bytes(8, 'big')) + metadata = { + 'order_type': 'sell', + 'token_id': token_hex, + 'amount_to_sell': amount_to_sell, + 'rate': rate, + 'min_chunk': min_chunk, + 'proof_of_reserve': proof, + 'partial_tx': partial_tx # full partial tx when chunk == amount + } + file_address = bytes.fromhex(bfp.upload_file( + wallet, json.dumps(metadata).encode(), config=config, password=password) + ) + chunks.append(file_address) + time.sleep(5) + + return slp.chunksToOpreturnOutput(chunks) + + +def parse_order_op_return(op_return): + chunks = slp.parseOpreturnToChunks(op_return, allow_op_0=False, allow_op_number=False) + order_type = chunks[1].decode() + parsed_data_dict = dict() + parsed_data_dict['order_type'] = order_type + + if order_type == 'BUY' or order_type == 'SELL': + parsed_data_dict['token_hex'] = chunks[2].hex() + parsed_data_dict['amount'] = int.from_bytes(chunks[3], 'big') + parsed_data_dict['rate'] = int.from_bytes(chunks[4], 'big') + parsed_data_dict['min_chunk'] = int.from_bytes(chunks[5], 'big') # TAKE ORDER DOESN'T HAVE THIS! Maybe make it return a dict? + parsed_data_dict['input_utxo'] = chunks[6].hex() + parsed_data_dict['input_vout'] = int.from_bytes(chunks[7], 'big') + parsed_data_dict['proof_of_reserve_tx'] = chunks[8].hex() + elif order_type == 'TAKE': + parsed_data_dict['token_hex'] = chunks[2].hex() + parsed_data_dict['order_id'] = int.from_bytes(chunks[3], 'big') + parsed_data_dict['amount'] = int.from_bytes(chunks[4], 'big') + parsed_data_dict['proof_of_reserve_tx'] = chunks[5].hex() + parsed_data_dict['rate'] = 1 # TODO TAKE orders don't have rate! Is this ok? + + return parsed_data_dict + + +def build_take_order_op_return(wallet, token_hex, order_txid_hex, amount: int, partial_tx: str, config={}, password=None): + + chunks = [LOCAD_ID, b'TAKE'] + + token_bytes = bytes.fromhex(token_hex) + assert len(token_bytes) == 32 + chunks.append(token_bytes) + + order_txid = bytes.fromhex(order_txid_hex) + assert len(order_txid) == 32 + chunks.append(order_txid) + + amount_to_buy = int(amount).to_bytes(8, 'big') + chunks.append(amount_to_buy) + + metadata = { + 'order_type': 'take', + 'token_id': token_hex, + 'amount': amount, + 'partial_tx': partial_tx # full partial tx when chunk == amount + } + + file_address = bytes.fromhex(bfp.upload_file( + wallet, json.dumps(metadata).encode(), config=config, password=password) + ) + chunks.append(file_address) + + time.sleep(5) + + return slp.chunksToOpreturnOutput(chunks) diff --git a/TokenDex/order_book.py b/TokenDex/order_book.py new file mode 100644 index 0000000..adaf82f --- /dev/null +++ b/TokenDex/order_book.py @@ -0,0 +1,147 @@ +import requests +import base64 +import json + +from electroncash import networks + +if networks.net.TESTNET: + bitdb_server_url = 'https://testnet-bitdb.opreturn.me/q/' +else: + bitdb_server_url = 'https://bitdb.fountainhead.cash/q/' + + +def query_to_bitdb_url(query, server_url=bitdb_server_url): + q = json.dumps(query) + query_path = base64.standard_b64encode(q.encode()).decode() + return server_url + query_path + + +def get_utxo_info_batch(network, utxo_list, callback): + """utxo_list = [['prev_h', 'prev_n']]""" + reqs = list() + for utxo in utxo_list: + reqs.append( + ('blockchain.utxo.get_info', utxo) + ) + network.send(reqs, callback) + + +def get_blockchain_sell_orders(token_hex): + get_sell_orders_query = { + "v": 2, + "q": { + "find": { + "out.s1": "DEX\u0000", + "out.s2": "SELL", + "out.b3": base64.standard_b64encode(bytes.fromhex(token_hex)).decode('ascii') + }, + "limit": 10000 + } + } + + url = query_to_bitdb_url(get_sell_orders_query) + res = requests.get(url) + assert res.status_code == 200 + data = res.json() + transactions = data['c'] + data['u'] + + orders = [] + for tx in transactions: + try: + op_return = tx['out'][0] + order_data = { + 'tx_id': tx['tx']['h'], + 'order_type': op_return['s2'], + 'token_id': op_return['h3'], + 'amount_to_sell': int.from_bytes(bytes.fromhex(op_return['h4']), 'big'), + 'rate': int.from_bytes(bytes.fromhex(op_return['h5']), 'big'), + 'min_chunk': int.from_bytes(bytes.fromhex(op_return['h6']), 'big'), + 'utxo_prevout_hash': op_return['h7'], + 'utxo_prevout_n': int.from_bytes(bytes.fromhex(op_return['h8']), 'big'), + 'proof_of_reserve': op_return['h9'] + } + orders.append(order_data) + except Exception as e: + print(e);raise e + return orders + + +def get_blockchain_buy_orders(token_hex): + get_sell_orders_query = { + "v": 2, + "q": { + "find": { + "out.s1": "DEX\u0000", + "out.s2": "BUY", + "out.b3": base64.standard_b64encode(bytes.fromhex(token_hex)).decode('ascii') + }, + "limit": 10000 + } + } + + url = query_to_bitdb_url(get_sell_orders_query) + res = requests.get(url) + assert res.status_code == 200 + data = res.json() + transactions = data['c'] + data['u'] + + orders = [] + for tx in transactions: + try: + op_return = tx['out'][0] + order_data = { + 'tx_id': tx['tx']['h'], + 'order_type': op_return['s2'], + 'token_id': op_return['h3'], + 'amount_to_buy': int.from_bytes(bytes.fromhex(op_return['h4']), 'big'), + 'rate': int.from_bytes(bytes.fromhex(op_return['h5']), 'big'), + 'min_chunk': int.from_bytes(bytes.fromhex(op_return['h6']), 'big'), + 'utxo_prevout_hash': op_return['h7'], + 'utxo_prevout_n': int.from_bytes(bytes.fromhex(op_return['h8']), 'big'), + 'proof_of_reserve': op_return['h9'] + } + orders.append(order_data) + except Exception as e: + print(e);raise e + return orders + + +def get_blockchain_take_orders(orders_hex): + get_sell_orders_query = { + "v": 2, + "q": { + "find": { + "out.s1": "DEX\u0000", + "out.s2": "TAKE", # TODO use token hex as out.b3 too? + "out.b4": { + "$in": [ + base64.standard_b64encode(bytes.fromhex(order_hex)).decode('ascii') for order_hex in orders_hex + ] + } + }, + "limit": 10000 + } + } + + url = query_to_bitdb_url(get_sell_orders_query) + res = requests.get(url) + assert res.status_code == 200 + data = res.json() + transactions = data['c'] + data['u'] + + orders = [] + for tx in transactions: + try: + op_return = tx['out'][0] + order_data = { + 'tx_id': tx['tx']['h'], + 'order_type': op_return['s2'], + 'token_hex': op_return['h3'], + 'order_id_to_take': op_return['h4'], + 'amount': int.from_bytes(bytes.fromhex(op_return['h4']), 'big'), + 'proof_of_reserve': op_return['h5'], + } + orders.append(order_data) + except Exception as e: + print(e);raise e + return orders diff --git a/TokenDex/proof_of_reserve.py b/TokenDex/proof_of_reserve.py new file mode 100644 index 0000000..4d1ee00 --- /dev/null +++ b/TokenDex/proof_of_reserve.py @@ -0,0 +1,36 @@ +from electroncash import bitcoin +from electroncash.address import Address + + +def sign_message(wallet, address: str, message: str, password=None): + try: + addr = Address.from_string(address) + except Exception as e: + raise e + assert addr.kind == addr.ADDR_P2PKH # must have a private key ie: not a smart contract + assert wallet.is_mine(addr) + + signature = wallet.sign_message(addr, message, password) + return signature + + +def generate_proof_of_reserve(wallet, inputs, password): + proof = dict() + for i in inputs: + address = i['address'] + message = i['prevout_hash'] + str(i['prevout_n']) + signatures = sign_message(wallet, address.to_cashaddr(), message, password) + proof[address.to_cashaddr()] = signatures.hex() + return proof + + +def verify_message(address: str, signature: str, message: str): + # todo: binascii.unhexlify(signature) + try: + addr = Address.from_string(address) + except Exception as e: + raise e + assert addr.kind == addr.ADDR_P2PKH # must have a private key ie: not a smart contract + + verified = bitcoin.verify_message(address, signature, message.encode('utf-8')) + return verified diff --git a/TokenDex/qt.py b/TokenDex/qt.py new file mode 100644 index 0000000..6925820 --- /dev/null +++ b/TokenDex/qt.py @@ -0,0 +1,21 @@ +from PyQt5 import QtGui + +from electroncash.plugins import BasePlugin, hook + +from . import ui + + +class Plugin(BasePlugin): + + @hook + def init_qt(self, gui): + for window in gui.windows: + # tab = ui.DexTab(window.wallet) + tab = ui.DexTab(window.wallet, window) + window.tabs.addTab(tab, QtGui.QIcon(":icons/tab_slp_icon.png"), "TokenDex") + + @hook + def on_new_window(self, window): + # tab = ui.DexTab(window.wallet) + tab = ui.DexTab(window.wallet, window) + window.tabs.addTab(tab, QtGui.QIcon(":icons/tab_slp_icon.png"), "TokenDex") diff --git a/TokenDex/transaction.py b/TokenDex/transaction.py new file mode 100644 index 0000000..c8ad487 --- /dev/null +++ b/TokenDex/transaction.py @@ -0,0 +1,128 @@ +import time + +from electroncash import slp +from electroncash.transaction import Transaction +from electroncash.slp_coinchooser import SlpCoinChooser +from electroncash.slp_checker import SlpTransactionChecker + +from .utils import AnyoneCanPaySingleTransaction, slp_get_change_address + + +def create_partial_slp_tx(wallet, slp_coin, amount_bch_to_receive: int, password=None): + bch_address = slp_coin['address'] # sends the payment back to the token UTXO address + bch_payment_output = (0, bch_address, int(amount_bch_to_receive)) + + wallet.add_input_info(slp_coin) + + # Create partial TX + tx = Transaction.from_io([slp_coin], [bch_payment_output], sign_schnorr=True) + tx.__class__ = AnyoneCanPaySingleTransaction # uses SIGHASH_SINGLE |SIGHASH_ANYONECANPAY | SIGHASH_FORKID + wallet.sign_transaction(tx, anyonecanpay=True, password=password) + + # print('partial tx generated:', tx.raw) + return tx + + +def complete_partial_slp_tx( + wallet, tx_hex, bch_amount_to_send, slp_amount_to_receive, token_hex, mandatory_coin=None, + config={}, domain=None, password=None +): + tx = Transaction(tx_hex) + tx.deserialize() + + tx_outputs = tx.outputs() + tx_inputs = tx.inputs() + assert len(tx_outputs) == len(tx_inputs) == 1 + assert tx.output_value() == bch_amount_to_send + + op_return_output = slp.buildSendOpReturnOutput_V1(token_hex, [0, slp_amount_to_receive]) + + tx_outputs.insert(0, op_return_output) + tx_outputs.insert(2, (0, wallet.get_addresses()[0], 546)) + assert len(tx_outputs) == 3 + + coins = wallet.get_spendable_coins(domain, config) + if mandatory_coin: + funding_tx = wallet.make_unsigned_transaction(coins, tx_outputs, config=config, mandatory_coins=[mandatory_coin]) + else: + funding_tx = wallet.make_unsigned_transaction(coins, tx_outputs, config=config) + funding_inputs = funding_tx.inputs() + + for i in funding_inputs: + wallet.add_input_info(i) + + for output in funding_tx.outputs(): # add change output if existed + if output not in tx.outputs(): + # print('Adding output') + tx.add_outputs([output]) + # wallet.sign_transaction(tx, anyonecanpay=False, password=password) + + funding_inputs.insert(1, tx_inputs[0]) + tx_inputs = funding_inputs + tx = Transaction.from_io(tx_inputs, tx_outputs) + wallet.sign_transaction(tx, anyonecanpay=False, password=password) + + return tx # return final tx + + +def create_signal_tx(wallet, op_return_output, config, domain=None): + coins = wallet.get_spendable_coins(domain, config) + outputs = [op_return_output] + tx = wallet.make_unsigned_transaction(coins, outputs, config=config) + return tx + + +def spend_slp_coin(wallet, token_hex, slp_coin, config={}, domain=None, password=None): + op_return_output = slp.buildSendOpReturnOutput_V1(token_hex, [slp_coin['token_value']]) + slp_msg = slp.SlpMessage.parseSlpOutputScript(op_return_output[1]) + token_outputs = slp_msg.op_return_fields['token_output'][1:] + assert len(token_outputs) == 1 + + output = (0, slp_coin['address'], 546) + bch_outputs = [op_return_output, output] + coins = wallet.get_spendable_coins(domain, config) + tx = wallet.make_unsigned_transaction(coins, bch_outputs, config=config, mandatory_coins=[slp_coin]) + wallet.sign_transaction(tx, password=password) + + SlpTransactionChecker.check_tx_slp(wallet, tx) + + status, tx_id = wallet.network.broadcast_transaction(tx) + + assert status + time.sleep(5) # TODO FIX THIS + return status + + +def generate_slp_utxo_of_specific_size(wallet, token_hex, utxo_size, config={}, domain=None, password=None): + slp_coins, op_return = SlpCoinChooser.select_coins(wallet, token_hex, utxo_size, config) + slp_msg = slp.SlpMessage.parseSlpOutputScript(op_return[1]) + token_outputs = slp_msg.op_return_fields['token_output'][1:] + assert len(token_outputs) < 3 + change_address = slp_get_change_address(wallet) + output = (0, change_address, 546) + bch_outputs = [op_return, output] + if len(token_outputs) > 1: # has change + bch_outputs.append(output) + coins = wallet.get_spendable_coins(domain, config) + tx = wallet.make_unsigned_transaction(coins, bch_outputs, config=config, mandatory_coins=slp_coins) + wallet.sign_transaction(tx, password=password) + + SlpTransactionChecker.check_tx_slp(wallet, tx) + + status, tx_id = wallet.network.broadcast_transaction(tx) + + assert status + + new_coin = { + 'address': change_address, + 'value': 546, + 'prevout_n': 1, + 'prevout_hash': tx_id, 'coinbase': False, + 'is_frozen_coin': False, + 'token_value': utxo_size, + 'token_id_hex': token_hex, + 'token_type': 'SLP1' + } + time.sleep(5) # TODO FIX THIS + return new_coin + diff --git a/TokenDex/ui.py b/TokenDex/ui.py new file mode 100644 index 0000000..b059eb0 --- /dev/null +++ b/TokenDex/ui.py @@ -0,0 +1,611 @@ +from decimal import Decimal as PyDecimal +import threading +import traceback +import queue + +from PyQt5 import QtWidgets +from PyQt5 import QtGui +from PyQt5 import QtCore + +from electroncash_gui.qt.amountedit import SLPAmountEdit, BTCAmountEdit +# from electroncash_gui.qt.util import ColorScheme +from electroncash_gui.qt.util import WaitingDialog +from electroncash.util import NotEnoughFunds, NotEnoughFundsSlp + +from . import dex + + +class DexTab(QtWidgets.QWidget): + got_order_book_data = QtCore.pyqtSignal() + orders_need_update = QtCore.pyqtSignal() + window_is_destroyed = threading.Event() + + def __init__(self, wallet, window, *args, **kwargs): + super(DexTab, self).__init__(*args, **kwargs) + + self.task_queue = queue.Queue() + self.working_thread = DexThread(None, self.task_queue) + self.working_thread.start() + self.working_thread.error_raised.connect(self.on_error) + self.wallet = wallet + self.window = window + self.config = self.window.config + self.password = None # TODO get password + self.dex = None + + self.blockchain_sell_orders = list() + self.blockchain_buy_orders = list() + self.blockchain_take_orders = list() + self.user_orders_data = list() + + self.layout = QtWidgets.QGridLayout() + self.setLayout(self.layout) + bold_font = QtGui.QFont() + bold_font.setBold(True) + + self.token_types_layout = QtWidgets.QGridLayout() + token_types_label = QtWidgets.QLabel("Token Type") + token_types_label.setFont(bold_font) + self.token_types_combo = QtWidgets.QComboBox() + self.token_combo_update_btn = QtWidgets.QPushButton("Update Token List") + + self._fill_token_type_combo() + + self.token_types_layout.addWidget(token_types_label, 0, 1) + self.token_types_layout.addWidget(self.token_types_combo, 0, 2, 1, 8) + self.token_types_layout.addWidget(self.token_combo_update_btn, 0, 10) + + self.layout.addLayout(self.token_types_layout, 0, 0, 1, 12) + + self.place_order_layout = QtWidgets.QGridLayout() + place_order_label = QtWidgets.QLabel("Place Order") + place_order_label.setFont(bold_font) + self.place_order_layout.addWidget(place_order_label, 0, 0) + self.order_type_combo = QtWidgets.QComboBox() + self.order_type_combo.addItem('SELL', 'SELL') + self.order_type_combo.addItem('BUY', 'BUY') + + self.place_order_layout.addWidget(QtWidgets.QLabel("Order Type"), 1, 0) + self.place_order_layout.addWidget(self.order_type_combo, 1, 1) + + self.coins_list_combo = QtWidgets.QComboBox() + self.place_order_layout.addWidget(QtWidgets.QLabel("Select Coin"), 2, 0) + self.place_order_layout.addWidget(self.coins_list_combo, 2, 1) + + self.place_order_layout.addWidget(QtWidgets.QLabel("Token Amount"), 3, 0) + self.slp_amount_edit = SLPAmountEdit('tokens', 0) + self.slp_amount_edit.setEnabled(False) + self.place_order_layout.addWidget(self.slp_amount_edit, 3, 1) + + self.place_order_layout.addWidget(QtWidgets.QLabel("BCH Amount"), 4, 0) + self.bch_amount_edit = BTCAmountEdit(lambda: 8) + self.bch_amount_edit.setEnabled(False) + self.place_order_layout.addWidget(self.bch_amount_edit, 4, 1) + + self.place_order_layout.addWidget(QtWidgets.QLabel("Rate"), 5, 0) + self.rate_edit = BTCAmountEdit(lambda: 8, is_int=True) + self.place_order_layout.addWidget(self.rate_edit, 5, 1) + + self.order_btn = QtWidgets.QPushButton("Order") + self.place_order_layout.addWidget(self.order_btn, 6, 0, 1, 2) + self.layout.addLayout(self.place_order_layout, 4, 0, 2, 2) + + self.order_book_layout = QtWidgets.QGridLayout() + buy_orders_label = QtWidgets.QLabel("Buy Orders") + buy_orders_label.setFont(bold_font) + sell_orders_label = QtWidgets.QLabel("Sell Orders") + sell_orders_label.setFont(bold_font) + + self.order_book_layout.addWidget(buy_orders_label, 0, 0, 1, 1) + self.order_book_layout.addWidget(sell_orders_label, 0, 1, 1, 1) + self.buy_orders = QtWidgets.QTableWidget() + self.buy_orders.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) + self.buy_orders.setColumnCount(4) + self.buy_orders.setHorizontalHeaderLabels(["BCH Amount", "Rate", "SLP To Pay", "Take Order"]) + self.buy_orders.horizontalHeader().setSectionResizeMode(QtWidgets.QHeaderView.Stretch) + self.order_book_layout.addWidget(self.buy_orders, 1, 0, 1, 1) + + self.sell_orders = QtWidgets.QTableWidget() + self.sell_orders.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) + self.sell_orders.setColumnCount(4) + self.sell_orders.setHorizontalHeaderLabels(["SLP Amount", "Rate", "BCH To Pay", "Take Order"]) + self.sell_orders.horizontalHeader().setSectionResizeMode(QtWidgets.QHeaderView.Stretch) + self.order_book_layout.addWidget(self.sell_orders, 1, 1, 1, 1) + + your_orders_label = QtWidgets.QLabel("Your Orders") + your_orders_label.setFont(bold_font) + take_orders_label = QtWidgets.QLabel("Take Orders") + take_orders_label.setFont(bold_font) + self.order_book_layout.addWidget(your_orders_label, 2, 0) + # self.order_book_layout.addWidget(take_orders_label, 2, 1) + + self.user_orders = QtWidgets.QTableWidget() + self.user_orders.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) + self.user_orders.setColumnCount(6) + self.user_orders.setHorizontalHeaderLabels([ + "Order Type", "Amount", "Rate", "BCH Payment", "Cancel Order", "Got Take Order" + ]) + self.user_orders.horizontalHeader().setSectionResizeMode(QtWidgets.QHeaderView.Stretch) + self.order_book_layout.addWidget(self.user_orders, 3, 0, 5, 5) + + self.refresh_btn = QtWidgets.QPushButton('Refresh Orders') + self.order_book_layout.addWidget(self.refresh_btn, 9, 0, 5, 5) + + # self.take_orders = QtWidgets.QTableWidget() + # self.take_orders.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) + # self.take_orders.setColumnCount(5) + # self.take_orders.setHorizontalHeaderLabels([ + # "Order Type", "Token Amount", "Rate", "BCH Amount", "Accept Order" + # ]) + # self.take_orders.horizontalHeader().setSectionResizeMode(QtWidgets.QHeaderView.Stretch) + # self.order_book_layout.addWidget(self.take_orders, 3, 1, 1, 1) + + self.layout.addLayout(self.order_book_layout, 1, 2, 5, 10) + + self.token_types_combo.currentIndexChanged.connect(self.token_type_index_changed) + self.order_type_combo.currentIndexChanged.connect(self.order_type_index_changed) + self.coins_list_combo.currentIndexChanged.connect(self.coins_list_index_changed) + + self.got_order_book_data.connect(self.handle_blockchain_orders) + self.orders_need_update.connect(self.update_orders) + self.bch_amount_edit.textChanged.connect(self.rate_changed) + self.slp_amount_edit.textChanged.connect(self.rate_changed) + self.rate_edit.textChanged.connect(self.rate_changed) + self.order_btn.clicked.connect(self.place_order) + self.refresh_btn.clicked.connect(self.get_blockchain_orders_with_waiting_dialog) + self.token_combo_update_btn.clicked.connect(self._fill_token_type_combo) + # token_types_combo.currentIndexChanged.connect() # TODO monitor for orders to place automatically? + + def on_error(self, exc_info): + print(exc_info[2]) + traceback.print_exception(*exc_info) + self.window.show_error(str(exc_info[1])) + + def _fill_token_type_combo(self): + blacklist = [ # blacklisted these tokens due to complications with them + 'fb1813fd1a53c1bed61f15c0479cc5315501e6da6a4d06da9d8122c1a4fabb6c', + 'dd21be4532d93661e8ffe16db6535af0fb8ee1344d1fef81a193e2b4cfa9fbc9' + ] + current_combo_data = [] + for i in range(self.token_types_combo.count()): + current_combo_data.append(self.token_types_combo.itemData(i)) + if len(current_combo_data) == 0: + self.token_types_combo.addItem(QtGui.QIcon(":icons/tab_coins.png"), "None", None) + for token in self.wallet.token_types: + if token not in current_combo_data and token not in blacklist: + self.token_types_combo.addItem(QtGui.QIcon(":icons/tab_slp_icon.png"), + self.wallet.token_types[token]['name'], token) + + def token_type_index_changed(self): + token_hex = self.token_types_combo.currentData() + if not token_hex: + self.bch_amount_edit.clear() + self.slp_amount_edit.clear() + self.rate_edit.clear() + self.coins_list_combo.clear() + return + token = self.wallet.token_types[token_hex] + self.slp_amount_edit.set_token(token['name'][:6], token['decimals']) + + self.order_type_index_changed() + + self.dex = dex.Dex(self.wallet, token_hex, self.config, self.password) + + self.update_orders() + + def update_orders(self): + # self.get_blockchain_orders() + self.get_blockchain_orders_with_waiting_dialog() + self.handle_user_orders() + + def get_blockchain_orders(self): + def wrapper(): + self.blockchain_sell_orders = self.dex.get_blockchain_sell_orders() + self.blockchain_buy_orders = self.dex.get_blockchain_buy_orders() + + self.get_user_orders() + order_hexes = [order['order_id'] for order in self.user_orders_data] + self.blockchain_take_orders = self.dex.get_blockchain_take_orders(order_hexes) + + utxo_list = [] + for order in self.blockchain_sell_orders: + utxo_list.append([order['utxo_prevout_hash'], order['utxo_prevout_n']]) + for order in self.blockchain_buy_orders: + utxo_list.append([order['utxo_prevout_hash'], order['utxo_prevout_n']]) + + def callback(result): + if not result['result']: + prevout_h = result['params'][0] + prevout_n = result['params'][1] + sell_order_indexes = [] + buy_order_indexes = [] + + for i, order in enumerate(self.blockchain_sell_orders): + if order['utxo_prevout_hash'] == prevout_h and order['utxo_prevout_n'] == prevout_n: + sell_order_indexes.append(i) + sorted(sell_order_indexes, reverse=True) + for i in sell_order_indexes: + del self.blockchain_sell_orders[i] + + for i, order in enumerate(self.blockchain_buy_orders): + if order['utxo_prevout_hash'] == prevout_h and order['utxo_prevout_n'] == prevout_n: + buy_order_indexes.append(i) + sorted(buy_order_indexes, reverse=True) + for i in buy_order_indexes: + del self.blockchain_buy_orders[i] + self.got_order_book_data.emit() + self.dex.get_utxo_info_batch(utxo_list, callback) + + self.got_order_book_data.emit() + self.task_queue.put(wrapper) + + def get_blockchain_orders_with_waiting_dialog(self): + if not self.token_types_combo.currentData(): + return + + def wrapper(): + self.blockchain_sell_orders = self.dex.get_blockchain_sell_orders() + self.blockchain_buy_orders = self.dex.get_blockchain_buy_orders() + + self.get_user_orders() + order_hexes = [order['order_id'] for order in self.user_orders_data] + self.blockchain_take_orders = self.dex.get_blockchain_take_orders(order_hexes) + + utxo_list = [] + for order in self.blockchain_sell_orders: + utxo_list.append([order['utxo_prevout_hash'], order['utxo_prevout_n']]) + for order in self.blockchain_buy_orders: + utxo_list.append([order['utxo_prevout_hash'], order['utxo_prevout_n']]) + + def callback(result): + if not result['result']: + prevout_h = result['params'][0] + prevout_n = result['params'][1] + sell_order_indexes = [] + buy_order_indexes = [] + + for i, order in enumerate(self.blockchain_sell_orders): + if order['utxo_prevout_hash'] == prevout_h and order['utxo_prevout_n'] == prevout_n: + sell_order_indexes.append(i) + sorted(sell_order_indexes, reverse=True) + for i in sell_order_indexes: + del self.blockchain_sell_orders[i] + + for i, order in enumerate(self.blockchain_buy_orders): + if order['utxo_prevout_hash'] == prevout_h and order['utxo_prevout_n'] == prevout_n: + buy_order_indexes.append(i) + sorted(buy_order_indexes, reverse=True) + for i in buy_order_indexes: + del self.blockchain_buy_orders[i] + self.got_order_book_data.emit() + self.dex.get_utxo_info_batch(utxo_list, callback) + + self.got_order_book_data.emit() + + WaitingDialog(self, "Fetching latest order book data. Please wait...", wrapper, on_error=self.on_error) + + def handle_blockchain_orders(self): + token_hex = self.token_types_combo.currentData() + if token_hex is not None: + token_decimals = self.wallet.token_types[token_hex]['decimals'] + + self.sell_orders.setRowCount(len(self.blockchain_sell_orders)) + self.buy_orders.setRowCount(len(self.blockchain_buy_orders)) + + for i, order in enumerate(self.blockchain_sell_orders): + amount_to_sell = order['amount_to_sell'] / PyDecimal(10**token_decimals) + rate = order['rate'] + total = amount_to_sell * rate / PyDecimal(10**8) # TODO BCH TO PAY? + amount_to_sell_column = QtWidgets.QTableWidgetItem(str(amount_to_sell)) + rate_column = QtWidgets.QTableWidgetItem(str(rate)) + total_column = QtWidgets.QTableWidgetItem(str(total)) + + self.sell_orders.setItem(i, 0, amount_to_sell_column) + self.sell_orders.setItem(i, 1, rate_column) + self.sell_orders.setItem(i, 2, total_column) + btn = QtWidgets.QPushButton() + btn.setText('Take Order') + # btn.setStyleSheet(ColorScheme.RED.as_stylesheet()) + btn.clicked.connect(self.take_sell_order) + + self.sell_orders.setCellWidget(i, 3, btn) + for i, order in enumerate(self.blockchain_buy_orders): + amount_to_buy = order['amount_to_buy'] / PyDecimal(10**8) + rate = order['rate'] + total = amount_to_buy * rate + amount_to_buy_column = QtWidgets.QTableWidgetItem(str(amount_to_buy)) + rate_column = QtWidgets.QTableWidgetItem(str(rate)) + total_column = QtWidgets.QTableWidgetItem(str(total)) + self.buy_orders.setItem(i, 0, total_column) + self.buy_orders.setItem(i, 1, rate_column) + self.buy_orders.setItem(i, 2, amount_to_buy_column) + + btn = QtWidgets.QPushButton() + btn.setText('Take Order') + # btn.setStyleSheet(ColorScheme.GREEN.as_stylesheet()) + btn.clicked.connect(self.take_buy_order) + + self.buy_orders.setCellWidget(i, 3, btn) + + self.handle_user_orders() + + def get_user_orders(self): + token_hex = self.token_types_combo.currentData() + user_dex_orders = self.wallet.storage.get('user_dex_orders', {}) + data = user_dex_orders.get(token_hex, []) + + self.user_orders_data = [] + for order in data: + # check if coin still exists in wallet + coin = order['coin'] + prevout_hash = coin['prevout_hash'] + prevout_n = coin['prevout_n'] + wallet_utxos = self.wallet.get_utxos(exclude_slp=False, exclude_frozen=False) + for utxo in wallet_utxos: + if utxo['prevout_hash'] == prevout_hash and utxo['prevout_n'] == prevout_n: + self.user_orders_data.append(order) + + def handle_user_orders(self): + self.get_user_orders() + + token_hex = self.token_types_combo.currentData() + token_decimals = self.wallet.token_types[token_hex]['decimals'] + self.user_orders.setRowCount(len(self.user_orders_data)) + + for i, order in enumerate(self.user_orders_data): + order_type = order['order_type'] + if order_type == 'sell' or order_type == 'take': + amount = order['amount_to_sell'] / PyDecimal(10**token_decimals) + elif order_type == 'buy': + amount = order['amount_to_buy'] / PyDecimal(10**token_decimals) + + order_type_column = QtWidgets.QTableWidgetItem(order_type) + total = amount * order['rate'] / 10**8 + amount_column = QtWidgets.QTableWidgetItem(str(amount)) + rate_column = QtWidgets.QTableWidgetItem(str(order['rate'])) + total_column = QtWidgets.QTableWidgetItem(str(total)) + self.user_orders.setItem(i, 0, order_type_column) + self.user_orders.setItem(i, 1, amount_column) + self.user_orders.setItem(i, 2, rate_column) + self.user_orders.setItem(i, 3, total_column) + btn = QtWidgets.QPushButton() + btn.setText('Cancel Order') + # btn.setStyleSheet(ColorScheme.RED.as_stylesheet()) + btn.clicked.connect(self.cancel_order) + self.user_orders.setCellWidget(i, 4, btn) + + for take_order in self.blockchain_take_orders: + assert take_order['order_type'] == 'TAKE' + order_id_to_take = take_order['order_id_to_take'] + + if order_id_to_take == order['order_id']: + btn = QtWidgets.QPushButton() + btn.setText('Accept Take Order') + # btn.setStyleSheet(ColorScheme.RED.as_stylesheet()) + btn.clicked.connect(self.accept_take_order) + self.user_orders.setCellWidget(i, 5, btn) + + def accept_take_order(self): + # TODO make sure current row index always equals self.user_orders_data index + current_row = self.user_orders.currentRow() + order = self.user_orders_data[current_row] + # print(current_row, order) + + for take_order in self.blockchain_take_orders: + assert take_order['order_type'] == 'TAKE' + order_id_to_take = take_order['order_id_to_take'] + + if order_id_to_take == order['order_id']: + tx_id = take_order['tx_id'] + + def take_order_wrapper(): + try: + coin = order['coin'] + from electroncash.address import Address + coin['address'] = Address.from_string(coin['address']) + print(self.dex.take_order(tx_id, mandatory_coin=coin)) + self.orders_need_update.emit() + except NotEnoughFunds: + raise NotEnoughFunds("Not Enough Funds") + except Exception as e: + print(e);raise e + raise (e) + + WaitingDialog(self, "Please wait...", take_order_wrapper, on_error=self.on_error) + break + + + def place_order(self): + order_type = self.order_type_combo.currentData() + rate = self.rate_edit.get_amount() + rate = PyDecimal(rate) / 10 ** 8 + assert rate.as_tuple().exponent == 0 # make sure it doesn't get round + rate = int(rate) + + if order_type == 'SELL': + bch_amount = self.bch_amount_edit.get_amount() + slp_coin = self.coins_list_combo.currentData() + slp_amount_to_sell = slp_coin['token_value'] + + def place_sell_order_wrapper(): + try: + print(self.dex.place_sell_order(slp_coin, slp_amount_to_sell, bch_amount, rate, slp_amount_to_sell)) + self.orders_need_update.emit() + except NotEnoughFunds: + raise NotEnoughFunds("Not Enough Funds") + WaitingDialog(self, "Please wait...", place_sell_order_wrapper, on_error=self.on_error) + elif order_type == 'BUY': + bch_amount = self.bch_amount_edit.get_amount() + bch_order_coin = self.coins_list_combo.currentData() + slp_amount_to_buy = self.slp_amount_edit.get_amount() + + def place_buy_order_wrapper(): + try: + print(self.dex.place_buy_order( + bch_order_coin, slp_amount_to_buy, rate, slp_amount_to_buy) + ) # TODO let the user set min chunk + self.orders_need_update.emit() + except NotEnoughFunds: + raise NotEnoughFunds("Not Enough Funds") + WaitingDialog(self, "Please wait...", place_buy_order_wrapper, on_error=self.on_error) + + def take_sell_order(self): + current_row = self.sell_orders.currentRow() + tx_id = self.blockchain_sell_orders[current_row]['tx_id'] + + def take_order_wrapper(): + try: + print(self.dex.take_order(tx_id)) + self.orders_need_update.emit() + except NotEnoughFunds: + raise NotEnoughFunds("Not Enough Funds") + except Exception as e: + print(e);raise e + raise(e) + + WaitingDialog(self, "Please wait...", take_order_wrapper, on_error=self.on_error) + + def cancel_order(self): + current_row = self.sell_orders.currentRow() + order = self.user_orders_data[current_row] + coin = order['coin'] + from electroncash.address import Address + coin['address'] = Address.from_string(coin['address']) + + if order['order_type'] == 'sell': + token_hex = self.token_types_combo.currentData() + + def spend_order_slp_coin_wrapper(): + try: + self.wallet.set_frozen_coin_state([coin], False) + tx = dex.transaction.spend_slp_coin( + self.wallet, token_hex, coin, self.config, None, self.password + ) + self.orders_need_update.emit() + except Exception as e: + self.wallet.set_frozen_coin_state([coin], True) + raise e + WaitingDialog( + self, "Please wait...", spend_order_slp_coin_wrapper, + on_error=self.on_error, on_success=lambda res: self.handle_user_orders() + ) + + elif order['order_type'] == 'buy': + self.window.spend_coins([coin]) + + def take_buy_order(self): + current_row = self.buy_orders.currentRow() + tx_id = self.blockchain_buy_orders[current_row]['tx_id'] + print(tx_id) + + def take_order_wrapper(): + try: + print(self.dex.take_order(tx_id)) + self.orders_need_update.emit() + except NotEnoughFunds: + raise NotEnoughFunds("Not Enough Funds") + WaitingDialog(self, "Please wait...", take_order_wrapper, on_error=self.on_error) + + def order_type_index_changed(self): + token_hex = self.token_types_combo.currentData() + if not token_hex: + return + token_decimals = self.wallet.token_types[token_hex]['decimals'] + order_type = self.order_type_combo.currentData() + self.bch_amount_edit.clear() + self.slp_amount_edit.clear() + self.rate_edit.clear() + self._fill_coins_list_combo(token_hex, order_type, token_decimals) + + def coins_list_index_changed(self): + token_hex = self.token_types_combo.currentData() + if not token_hex or not self.coins_list_combo.currentData(): + return + order_type = self.order_type_combo.currentData() + if order_type == 'SELL': + token_decimals = self.wallet.token_types[token_hex]['decimals'] + current_slp_amount = self.coins_list_combo.currentData()['token_value'] / PyDecimal(10 ** token_decimals) + self.slp_amount_edit.setAmount(current_slp_amount * 10**token_decimals) + elif order_type == 'BUY': + current_bch_amount = self.coins_list_combo.currentData()['value'] + self.bch_amount_edit.setAmount(current_bch_amount) + + def _fill_coins_list_combo(self, token_hex, order_type, token_decimals): + self.coins_list_combo.clear() + if order_type == 'SELL': + coins = self.wallet.get_slp_spendable_coins(token_hex, None, {}) + for coin in coins: + slp_amount = coin['token_value'] / PyDecimal(10 ** token_decimals) + self.coins_list_combo.addItem( + ', SLP Amount: '.join(["...".join([ + str(coin['address'])[:6], + str(coin['address'])[-6:]]), + str(slp_amount) + ]), coin + ) + elif order_type == 'BUY': + coins = self.wallet.get_spendable_coins(None, {}) # TODO: pass the config properly + for coin in coins: + self.coins_list_combo.addItem( + ', Amount: '.join(["...".join([ + str(coin['address'])[:6], + str(coin['address'])[-6:]]), + str(coin['value']) + ]), coin + ) + self.coins_list_index_changed() + + def rate_changed(self): + order_type = self.order_type_combo.currentData() + token_hex = self.token_types_combo.currentData() + rate = self.rate_edit.get_amount() + if rate is not None and token_hex is not None: + token_decimals = self.wallet.token_types[token_hex]['decimals'] + rate = PyDecimal(rate) / 10**8 + assert rate.as_tuple().exponent == 0 # make sure it doesn't get round + rate = int(rate) + if order_type == 'SELL': # UTXO is SLP + slp_amount = self.slp_amount_edit.get_amount() + if slp_amount: + self.bch_amount_edit.setAmount(rate * slp_amount / 10**token_decimals) + elif order_type == 'BUY': # UTXO is BCH + + bch_amount = self.bch_amount_edit.get_amount() + if bch_amount: + self.slp_amount_edit.setAmount(bch_amount / rate * 10**token_decimals if rate != 0 else 0) + + def background_thread_loop(self): + while not self.isHidden(): # Find a better way + try: + print('loop') + task = self.task_queue.get(timeout=3) + print("running", task.__name__) + task() + except Exception as e: + pass + + +class DexThread(QtCore.QThread): + error_raised = QtCore.pyqtSignal(str) + + def __init__(self, parent=None, task_queue=None): + super(DexThread, self).__init__(parent) + self.task_queue = task_queue + + def run(self): + while True: + try: + task = self.task_queue.get(timeout=3) + print("running", task.__name__) + task() + except queue.Empty: + pass + except NotEnoughFunds as e: + raise NotEnoughFunds("Not Enough Funds") + except Exception as e: + self.error_raised.emit(e.__class__.__name__ + ' ' + str(e)) + + +def generate_dex_tab(wallet, window): + tab = DexTab(wallet, window) + return tab diff --git a/TokenDex/utils.py b/TokenDex/utils.py new file mode 100644 index 0000000..8729bf3 --- /dev/null +++ b/TokenDex/utils.py @@ -0,0 +1,81 @@ +from electroncash.transaction import Transaction +from electroncash.bitcoin import * + + +def slp_get_change_address(wallet): + """ copied from main_window.py - start of logic copied from wallet.py """ + addrs = wallet.get_change_addresses()[-wallet.gap_limit_for_change:] + if wallet.use_change and addrs: + # New change addresses are created only after a few + # confirmations. Select the unused addresses within the + # gap limit; if none take one at random + change_addrs = [addr for addr in addrs if + wallet.get_num_tx(addr) == 0] + if not change_addrs: + import random + change_addrs = [random.choice(addrs)] + change_addr = change_addrs[0] + elif len(change_addrs) > 1: + change_addr = change_addrs[1] + else: + change_addr = change_addrs[0] + else: + change_addr = wallet.get_addresses()[0] + return change_addr + +class AnyoneCanPaySingleTransaction(Transaction): + + def serialize_preimage(self, i, nHashType=0x00000041, use_cache = False): + """ See `.calc_common_sighash` for explanation of use_cache feature """ + if not (nHashType & 0xff) in [0x41, 0xc1, 0xC3]: + raise ValueError("other hashtypes not supported; submit a PR to fix this!") + + anyonecanpay = True if (nHashType & 0x80) > 0 else False + + nVersion = int_to_hex(self.version, 4) + nHashType = int_to_hex(nHashType, 4) + nLocktime = int_to_hex(self.locktime, 4) + + txin = self.inputs()[i] + outpoint = self.serialize_outpoint(txin) + preimage_script = self.get_preimage_script(txin) + scriptCode = var_int(len(preimage_script) // 2) + preimage_script + try: + amount = int_to_hex(txin['value'], 8) + except KeyError: + raise InputValueMissing + nSequence = int_to_hex(txin.get('sequence', 0xffffffff - 1), 4) + + hashPrevouts, hashSequence, hashOutputs = self.calc_common_sighash(use_cache = use_cache) + + if anyonecanpay or nHashType & 0xff != 0xC3: + hashPrevouts = "0000000000000000000000000000000000000000000000000000000000000000" + hashSequence = "0000000000000000000000000000000000000000000000000000000000000000" + else: + hashPrevouts = bh2u(hashPrevouts) + hashSequence = bh2u(hashSequence) + + preimage = nVersion + hashPrevouts + hashSequence + outpoint + scriptCode + amount + nSequence + bh2u(hashOutputs) + nLocktime + nHashType + return preimage + + + def _sign_txin(self, i, j, sec, compressed, *, use_cache=False, anyonecanpay=False): + '''Note: precondition is self._inputs is valid (ie: tx is already deserialized)''' + pubkey = public_key_from_private_key(sec, compressed) + # add signature + nHashType = 0x00000043 # hardcoded, perhaps should be taken from unsigned input dict + if anyonecanpay: + nHashType += 0x00000080 + pre_hash = Hash(bfh(self.serialize_preimage(i, nHashType))) + if self._sign_schnorr: + sig = self._schnorr_sign(pubkey, sec, pre_hash) + else: + sig = self._ecdsa_sign(sec, pre_hash) + reason = [] + if not self.verify_signature(bfh(pubkey), sig, pre_hash, reason=reason): + print_error(f"Signature verification failed for input#{i} sig#{j}, reason: {str(reason)}") + return None + txin = self._inputs[i] + txin['signatures'][j] = bh2u(sig + bytes((nHashType & 0xff,))) + txin['pubkeys'][j] = pubkey # needed for fd keys + return txin diff --git a/TokenDex/validation.py b/TokenDex/validation.py new file mode 100644 index 0000000..0dbdb4e --- /dev/null +++ b/TokenDex/validation.py @@ -0,0 +1,36 @@ +from queue import Queue + +from electroncash.slp import SlpMessage +from electroncash import slp_validator_0x01 +from electroncash.transaction import Transaction + + +def validate_transaction(wallet, txid, token_hex, prevout_n, amount): + status, raw_tx = wallet.network.get_raw_tx_for_txid(txid) + assert status is True + tx = Transaction(raw_tx) + print(tx) + q = Queue() + + graphdb = slp_validator_0x01.GraphContext(name='DEXValidation') + job = graphdb.make_job(tx, wallet, wallet.network) + if not job: # none slp tx + return + job.add_callback(q.put, way='weakmethod') + + job = q.get() + assert not job.running + try: + n = next(iter(job.nodes.values())) + validity_name = job.graph.validator.validity_states[n.validity] + print(n, validity_name) + assert job.nodes[txid].validity == 1 + assert job.nodes[txid].outputs[prevout_n] == amount + op_return = tx.outputs()[0][1] + slp_msg = SlpMessage.parseSlpOutputScript(op_return) + print(slp_msg.op_return_fields) + assert slp_msg.op_return_fields['token_id_hex'] == token_hex + return True + except Exception as e: + print(e);raise e + return False diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000..e12c029 Binary files /dev/null and b/assets/icon.png differ diff --git a/exportPlugin.sh b/exportPlugin.sh new file mode 100644 index 0000000..567f451 --- /dev/null +++ b/exportPlugin.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +zip -r TokenDex manifest.json TokenDex/ diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..f197211 --- /dev/null +++ b/manifest.json @@ -0,0 +1,11 @@ +{ + "display_name": "Token Dex", + "description": "SLP on-chain noncustodial token swap", + "version": "0.01", + "project_url": "https://github.com/OPReturnCode/TokenDex", + "minimum_ec_version": "3.0.0", + "package_name": "TokenDex", + "available_for": [ + "qt" + ] +}