From cbe2d57d66087f2c4bc9d5fd151b232ce6e46b45 Mon Sep 17 00:00:00 2001 From: OPReturnCode Date: Wed, 21 Sep 2022 16:02:55 +0100 Subject: [PATCH] Merge dev squashed --- README.md | 30 ++ TokenDex/.gitignore | 129 ++++++++ TokenDex/LICENSE | 21 ++ TokenDex/__init__.py | 3 + TokenDex/bfp.py | 114 +++++++ TokenDex/dex.py | 174 ++++++++++ TokenDex/order.py | 149 +++++++++ TokenDex/order_book.py | 147 +++++++++ TokenDex/proof_of_reserve.py | 36 +++ TokenDex/qt.py | 21 ++ TokenDex/transaction.py | 128 ++++++++ TokenDex/ui.py | 611 +++++++++++++++++++++++++++++++++++ TokenDex/utils.py | 81 +++++ TokenDex/validation.py | 36 +++ assets/icon.png | Bin 0 -> 31774 bytes exportPlugin.sh | 3 + manifest.json | 11 + 17 files changed, 1694 insertions(+) create mode 100644 README.md create mode 100644 TokenDex/.gitignore create mode 100644 TokenDex/LICENSE create mode 100644 TokenDex/__init__.py create mode 100644 TokenDex/bfp.py create mode 100644 TokenDex/dex.py create mode 100644 TokenDex/order.py create mode 100644 TokenDex/order_book.py create mode 100644 TokenDex/proof_of_reserve.py create mode 100644 TokenDex/qt.py create mode 100644 TokenDex/transaction.py create mode 100644 TokenDex/ui.py create mode 100644 TokenDex/utils.py create mode 100644 TokenDex/validation.py create mode 100644 assets/icon.png create mode 100644 exportPlugin.sh create mode 100644 manifest.json 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 0000000000000000000000000000000000000000..e12c02950b8b1dc5b8d3891e9802c099be722f6f GIT binary patch literal 31774 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