├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── MANIFEST.in ├── README.md ├── setup.cfg ├── setup.py ├── tests └── client_tests │ ├── __init__.py │ ├── integration │ ├── __init__.py │ ├── test_blockchain_reorganization.py │ ├── test_reconnect.py │ ├── test_sync.py │ └── test_transactions.py │ └── unit │ ├── __init__.py │ ├── key_fixtures.py │ ├── test_account.py │ ├── test_bcd_data_stream.py │ ├── test_bip32.py │ ├── test_coinselection.py │ ├── test_database.py │ ├── test_hash.py │ ├── test_headers.py │ ├── test_ledger.py │ ├── test_mnemonic.py │ ├── test_script.py │ ├── test_transaction.py │ ├── test_utils.py │ └── test_wallet.py ├── torba.png ├── torba ├── __init__.py ├── client │ ├── __init__.py │ ├── baseaccount.py │ ├── basedatabase.py │ ├── baseheader.py │ ├── baseledger.py │ ├── basemanager.py │ ├── basenetwork.py │ ├── basescript.py │ ├── basetransaction.py │ ├── bcd_data_stream.py │ ├── bip32.py │ ├── cli.py │ ├── coinselection.py │ ├── constants.py │ ├── errors.py │ ├── hash.py │ ├── mnemonic.py │ ├── util.py │ ├── wallet.py │ └── words │ │ ├── __init__.py │ │ ├── chinese_simplified.py │ │ ├── english.py │ │ ├── japanese.py │ │ ├── portuguese.py │ │ └── spanish.py ├── coin │ ├── __init__.py │ ├── bitcoincash.py │ └── bitcoinsegwit.py ├── orchstr8 │ ├── __init__.py │ ├── cli.py │ ├── node.py │ └── service.py ├── rpc │ ├── __init__.py │ ├── framing.py │ ├── jsonrpc.py │ ├── session.py │ ├── socks.py │ └── util.py ├── server │ ├── __init__.py │ ├── block_processor.py │ ├── cli.py │ ├── coins.py │ ├── daemon.py │ ├── db.py │ ├── enum.py │ ├── env.py │ ├── hash.py │ ├── history.py │ ├── mempool.py │ ├── merkle.py │ ├── peer.py │ ├── peers.py │ ├── script.py │ ├── server.py │ ├── session.py │ ├── storage.py │ ├── text.py │ ├── tx.py │ └── util.py ├── stream.py ├── tasks.py ├── testcase.py ├── ui │ └── __init__.py └── workbench │ ├── Makefile │ ├── __init__.py │ ├── _blockchain_dock.py │ ├── _output_dock.py │ ├── application.py │ ├── blockchain_dock.ui │ └── output_dock.ui └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | # packaging 2 | torba.egg-info/ 3 | dist/ 4 | 5 | # PyCharm 6 | .idea/ 7 | 8 | # testing 9 | .tox/ 10 | tests/client_tests/unit/bitcoin_headers 11 | torba/bin 12 | 13 | # cache and logs 14 | __pycache__/ 15 | .mypy_cache/ 16 | _trial_temp/ 17 | _trial_temp-*/ 18 | 19 | # OS X DS_Store 20 | *.DS_Store 21 | 22 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: xenial 2 | sudo: true 3 | language: python 4 | python: "3.7" 5 | 6 | jobs: 7 | include: 8 | 9 | - stage: code quality 10 | name: "pylint & mypy" 11 | install: 12 | - pip install pylint mypy 13 | - pip install -e . 14 | script: 15 | - pylint --rcfile=setup.cfg torba 16 | - mypy --ignore-missing-imports torba 17 | after_success: skip 18 | 19 | - &tests 20 | stage: tests 21 | env: TESTTYPE=unit 22 | install: 23 | - pip install tox-travis 24 | script: tox 25 | - <<: *tests 26 | env: TESTTYPE=integration 27 | 28 | after_success: 29 | - pip install coverage 30 | - coverage combine tests/ 31 | - bash <(curl -s https://codecov.io/bash) 32 | 33 | cache: 34 | directories: 35 | - $HOME/.cache/pip 36 | - $TRAVIS_BUILD_DIR/.tox 37 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lbryio/torba/190304344c0ff68f8a24cf50272307a11bf7f62b/CHANGELOG.md -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 LBRY Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include CHANGELOG.md 3 | include LICENSE 4 | recursive-include torba *.txt *.py 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # <img src="https://raw.githubusercontent.com/lbryio/torba/master/torba.png" alt="Torba" width="42" height="30" /> Torba [](https://travis-ci.org/lbryio/torba) [](https://codecov.io/gh/lbryio/torba) 2 | 3 | A new wallet library to help bitcoin based projects build fast, correct and scalable crypto currency wallets in Python. 4 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [coverage:run] 2 | branch = True 3 | 4 | [coverage:paths] 5 | source = 6 | torba 7 | .tox/*/lib/python*/site-packages/torba 8 | 9 | [cryptography.*,coincurve.*,pbkdf2] 10 | ignore_missing_imports = True 11 | 12 | [pylint] 13 | ignore=words,server,workbench,rpc 14 | max-args=10 15 | max-line-length=110 16 | good-names=T,t,n,i,j,k,x,y,s,f,d,h,c,e,op,db,tx,io,cachedproperty,log,id 17 | valid-metaclass-classmethod-first-arg=mcs 18 | disable= 19 | fixme, 20 | broad-except, 21 | no-else-return, 22 | cyclic-import, 23 | missing-docstring, 24 | duplicate-code, 25 | expression-not-assigned, 26 | inconsistent-return-statements, 27 | too-few-public-methods, 28 | too-many-locals, 29 | too-many-branches, 30 | too-many-arguments, 31 | too-many-statements, 32 | too-many-public-methods, 33 | too-many-instance-attributes, 34 | protected-access, 35 | unused-argument 36 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from setuptools import setup, find_packages 4 | 5 | import torba 6 | 7 | BASE = os.path.dirname(__file__) 8 | with open(os.path.join(BASE, 'README.md'), encoding='utf-8') as fh: 9 | long_description = fh.read() 10 | 11 | REQUIRES = [ 12 | 'aiohttp==3.5.4', 13 | 'cffi==1.12.1', # TODO: 1.12.2 fails on travis in wine 14 | 'coincurve==11.0.0', 15 | 'pbkdf2==1.3', 16 | 'cryptography==2.5', 17 | 'attrs==18.2.0', 18 | 'pylru==1.1.0' 19 | ] 20 | if sys.platform.startswith('linux'): 21 | REQUIRES.append('plyvel==1.0.5') 22 | 23 | 24 | setup( 25 | name='torba', 26 | version=torba.__version__, 27 | url='https://github.com/lbryio/torba', 28 | license='MIT', 29 | author='LBRY Inc.', 30 | author_email='hello@lbry.io', 31 | description='Wallet client/server framework for bitcoin based currencies.', 32 | long_description=long_description, 33 | long_description_content_type="text/markdown", 34 | keywords='wallet,crypto,currency,money,bitcoin,electrum,electrumx', 35 | classifiers=[ 36 | 'Framework :: AsyncIO', 37 | 'Intended Audience :: Developers', 38 | 'Intended Audience :: System Administrators', 39 | 'License :: OSI Approved :: MIT License', 40 | 'Programming Language :: Python :: 3', 41 | 'Operating System :: OS Independent', 42 | 'Topic :: Internet', 43 | 'Topic :: Software Development :: Testing', 44 | 'Topic :: Software Development :: Libraries :: Python Modules', 45 | 'Topic :: System :: Benchmark', 46 | 'Topic :: System :: Distributed Computing', 47 | 'Topic :: Utilities', 48 | ], 49 | packages=find_packages(exclude=('tests',)), 50 | python_requires='>=3.6', 51 | install_requires=REQUIRES, 52 | extras_require={ 53 | 'gui': ( 54 | 'pyside2', 55 | ) 56 | }, 57 | entry_points={ 58 | 'console_scripts': [ 59 | 'torba-client=torba.client.cli:main', 60 | 'torba-server=torba.server.cli:main', 61 | 'orchstr8=torba.orchstr8.cli:main', 62 | ], 63 | 'gui_scripts': [ 64 | 'torba=torba.ui:main [gui]', 65 | 'torba-workbench=torba.workbench:main [gui]', 66 | ] 67 | } 68 | ) 69 | -------------------------------------------------------------------------------- /tests/client_tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lbryio/torba/190304344c0ff68f8a24cf50272307a11bf7f62b/tests/client_tests/__init__.py -------------------------------------------------------------------------------- /tests/client_tests/integration/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lbryio/torba/190304344c0ff68f8a24cf50272307a11bf7f62b/tests/client_tests/integration/__init__.py -------------------------------------------------------------------------------- /tests/client_tests/integration/test_blockchain_reorganization.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from torba.testcase import IntegrationTestCase 3 | 4 | 5 | class BlockchainReorganizationTests(IntegrationTestCase): 6 | 7 | VERBOSITY = logging.WARN 8 | 9 | async def assertBlockHash(self, height): 10 | self.assertEqual( 11 | self.ledger.headers.hash(height).decode(), 12 | await self.blockchain.get_block_hash(height) 13 | ) 14 | 15 | async def test_reorg(self): 16 | # invalidate current block, move forward 2 17 | self.assertEqual(self.ledger.headers.height, 200) 18 | await self.assertBlockHash(200) 19 | await self.blockchain.invalidate_block(self.ledger.headers.hash(200).decode()) 20 | await self.blockchain.generate(2) 21 | await self.ledger.on_header.where(lambda e: e.height == 201) 22 | self.assertEqual(self.ledger.headers.height, 201) 23 | await self.assertBlockHash(200) 24 | await self.assertBlockHash(201) 25 | 26 | # invalidate current block, move forward 3 27 | await self.blockchain.invalidate_block(self.ledger.headers.hash(200).decode()) 28 | await self.blockchain.generate(3) 29 | await self.ledger.on_header.where(lambda e: e.height == 202) 30 | self.assertEqual(self.ledger.headers.height, 202) 31 | await self.assertBlockHash(200) 32 | await self.assertBlockHash(201) 33 | await self.assertBlockHash(202) 34 | -------------------------------------------------------------------------------- /tests/client_tests/integration/test_reconnect.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import asyncio 3 | from unittest.mock import Mock 4 | 5 | from torba.client.basenetwork import BaseNetwork 6 | from torba.rpc import RPCSession 7 | from torba.testcase import IntegrationTestCase, AsyncioTestCase 8 | 9 | 10 | class ReconnectTests(IntegrationTestCase): 11 | 12 | VERBOSITY = logging.WARN 13 | 14 | async def test_connection_drop_still_receives_events_after_reconnected(self): 15 | address1 = await self.account.receiving.get_or_create_usable_address() 16 | self.ledger.network.client.connection_lost(Exception()) 17 | sendtxid = await self.blockchain.send_to_address(address1, 1.1337) 18 | await self.on_transaction_id(sendtxid) # mempool 19 | await self.blockchain.generate(1) 20 | await self.on_transaction_id(sendtxid) # confirmed 21 | 22 | await self.assertBalance(self.account, '1.1337') 23 | # is it real? are we rich!? let me see this tx... 24 | d = self.ledger.network.get_transaction(sendtxid) 25 | # what's that smoke on my ethernet cable? oh no! 26 | self.ledger.network.client.connection_lost(Exception()) 27 | with self.assertRaises(asyncio.CancelledError): 28 | await d 29 | # rich but offline? no way, no water, let's retry 30 | with self.assertRaisesRegex(ConnectionError, 'connection is not available'): 31 | await self.ledger.network.get_transaction(sendtxid) 32 | # * goes to pick some water outside... * time passes by and another donation comes in 33 | sendtxid = await self.blockchain.send_to_address(address1, 42) 34 | await self.blockchain.generate(1) 35 | # omg, the burned cable still works! torba is fire proof! 36 | await self.ledger.network.get_transaction(sendtxid) 37 | 38 | async def test_timeout_then_reconnect(self): 39 | await self.conductor.spv_node.stop() 40 | self.assertFalse(self.ledger.network.is_connected) 41 | await self.conductor.spv_node.start(self.conductor.blockchain_node) 42 | await self.ledger.network.on_connected.first 43 | self.assertTrue(self.ledger.network.is_connected) 44 | 45 | 46 | class ServerPickingTestCase(AsyncioTestCase): 47 | async def _make_fake_server(self, latency=1.0, port=1337): 48 | # local fake server with artificial latency 49 | proto = RPCSession() 50 | proto.handle_request = lambda _: asyncio.sleep(latency) 51 | server = await self.loop.create_server(lambda: proto, host='127.0.0.1', port=port) 52 | self.addCleanup(server.close) 53 | return ('127.0.0.1', port) 54 | 55 | async def test_pick_fastest(self): 56 | ledger = Mock(config={ 57 | 'default_servers': [ 58 | await self._make_fake_server(latency=1.5, port=1340), 59 | await self._make_fake_server(latency=0.1, port=1337), 60 | await self._make_fake_server(latency=1.0, port=1339), 61 | await self._make_fake_server(latency=0.5, port=1338), 62 | ], 63 | 'connect_timeout': 30 64 | }) 65 | 66 | network = BaseNetwork(ledger) 67 | self.addCleanup(network.stop) 68 | asyncio.ensure_future(network.start()) 69 | await asyncio.wait_for(network.on_connected.first, timeout=1) 70 | self.assertTrue(network.is_connected) 71 | self.assertEqual(network.client.server, ('127.0.0.1', 1337)) 72 | # ensure we are connected to all of them 73 | self.assertEqual(len(network.session_pool.sessions), 4) 74 | self.assertTrue(all([not session.is_closing() for session in network.session_pool.sessions])) 75 | -------------------------------------------------------------------------------- /tests/client_tests/integration/test_sync.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | from torba.testcase import IntegrationTestCase, WalletNode 4 | from torba.client.constants import CENT 5 | 6 | 7 | class SyncTests(IntegrationTestCase): 8 | 9 | VERBOSITY = logging.WARN 10 | 11 | def __init__(self, *args, **kwargs): 12 | super().__init__(*args, **kwargs) 13 | self.api_port = 5280 14 | self.started_nodes = [] 15 | 16 | async def asyncTearDown(self): 17 | for node in self.started_nodes: 18 | try: 19 | await node.stop(cleanup=True) 20 | except Exception as e: 21 | print(e) 22 | await super().asyncTearDown() 23 | 24 | async def make_wallet_node(self, seed=None): 25 | self.api_port += 1 26 | wallet_node = WalletNode( 27 | self.wallet_node.manager_class, 28 | self.wallet_node.ledger_class, 29 | port=self.api_port 30 | ) 31 | await wallet_node.start(self.conductor.spv_node, seed) 32 | self.started_nodes.append(wallet_node) 33 | return wallet_node 34 | 35 | async def test_nodes_with_same_account_stay_in_sync(self): 36 | # destination node/account for receiving TXs 37 | node0 = await self.make_wallet_node() 38 | account0 = node0.account 39 | # main node/account creating TXs 40 | node1 = self.wallet_node 41 | account1 = self.wallet_node.account 42 | # mirror node/account, expected to reflect everything in main node as it happens 43 | node2 = await self.make_wallet_node(account1.seed) 44 | account2 = node2.account 45 | 46 | self.assertNotEqual(account0.id, account1.id) 47 | self.assertEqual(account1.id, account2.id) 48 | await self.assertBalance(account0, '0.0') 49 | await self.assertBalance(account1, '0.0') 50 | await self.assertBalance(account2, '0.0') 51 | self.assertEqual(await account0.get_address_count(chain=0), 20) 52 | self.assertEqual(await account1.get_address_count(chain=0), 20) 53 | self.assertEqual(await account2.get_address_count(chain=0), 20) 54 | self.assertEqual(await account1.get_address_count(chain=1), 6) 55 | self.assertEqual(await account2.get_address_count(chain=1), 6) 56 | 57 | # check that main node and mirror node generate 5 address to fill gap 58 | fifth_address = (await account1.receiving.get_addresses())[4] 59 | await self.blockchain.send_to_address(fifth_address, 1.00) 60 | await asyncio.wait([ 61 | account1.ledger.on_address.first, 62 | account2.ledger.on_address.first 63 | ]) 64 | self.assertEqual(await account1.get_address_count(chain=0), 25) 65 | self.assertEqual(await account2.get_address_count(chain=0), 25) 66 | await self.assertBalance(account1, '1.0') 67 | await self.assertBalance(account2, '1.0') 68 | 69 | await self.blockchain.generate(1) 70 | 71 | # pay 0.01 from main node to receiving node, would have increased change addresses 72 | address0 = (await account0.receiving.get_addresses())[0] 73 | hash0 = self.ledger.address_to_hash160(address0) 74 | tx = await account1.ledger.transaction_class.create( 75 | [], 76 | [self.ledger.transaction_class.output_class.pay_pubkey_hash(CENT, hash0)], 77 | [account1], account1 78 | ) 79 | await self.broadcast(tx) 80 | await asyncio.wait([ 81 | account0.ledger.wait(tx), 82 | account1.ledger.wait(tx), 83 | account2.ledger.wait(tx), 84 | ]) 85 | self.assertEqual(await account0.get_address_count(chain=0), 21) 86 | self.assertGreater(await account1.get_address_count(chain=1), 6) 87 | self.assertGreater(await account2.get_address_count(chain=1), 6) 88 | await self.assertBalance(account0, '0.01') 89 | await self.assertBalance(account1, '0.989876') 90 | await self.assertBalance(account2, '0.989876') 91 | 92 | await self.blockchain.generate(1) 93 | 94 | # create a new mirror node and see if it syncs to same balance from scratch 95 | node3 = await self.make_wallet_node(account1.seed) 96 | account3 = node3.account 97 | await self.assertBalance(account3, '0.989876') 98 | -------------------------------------------------------------------------------- /tests/client_tests/integration/test_transactions.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import asyncio 3 | from itertools import chain 4 | from torba.testcase import IntegrationTestCase 5 | from torba.client.util import satoshis_to_coins, coins_to_satoshis 6 | 7 | 8 | class BasicTransactionTests(IntegrationTestCase): 9 | 10 | VERBOSITY = logging.WARN 11 | 12 | async def test_variety_of_transactions_and_longish_history(self): 13 | await self.blockchain.generate(300) 14 | await self.assertBalance(self.account, '0.0') 15 | addresses = await self.account.receiving.get_addresses() 16 | 17 | # send 10 coins to first 10 receiving addresses and then 10 transactions worth 10 coins each 18 | # to the 10th receiving address for a total of 30 UTXOs on the entire account 19 | sends = list(chain( 20 | (self.blockchain.send_to_address(address, 10) for address in addresses[:10]), 21 | (self.blockchain.send_to_address(addresses[9], 10) for _ in range(10)) 22 | )) 23 | # use batching to reduce issues with send_to_address on cli 24 | for batch in range(0, len(sends), 10): 25 | txids = await asyncio.gather(*sends[batch:batch+10]) 26 | await asyncio.wait([self.on_transaction_id(txid) for txid in txids]) 27 | await self.assertBalance(self.account, '200.0') 28 | self.assertEqual(20, await self.account.get_utxo_count()) 29 | 30 | # address gap should have increase by 10 to cover the first 10 addresses we've used up 31 | addresses = await self.account.receiving.get_addresses() 32 | self.assertEqual(30, len(addresses)) 33 | 34 | # there used to be a sync bug which failed to save TXIs between 35 | # daemon restarts, clearing cache replicates that behavior 36 | self.ledger._tx_cache.clear() 37 | 38 | # spend from each of the first 10 addresses to the subsequent 10 addresses 39 | txs = [] 40 | for address in addresses[10:20]: 41 | txs.append(await self.ledger.transaction_class.create( 42 | [], 43 | [self.ledger.transaction_class.output_class.pay_pubkey_hash( 44 | coins_to_satoshis('1.0'), self.ledger.address_to_hash160(address) 45 | )], 46 | [self.account], self.account 47 | )) 48 | await asyncio.wait([self.broadcast(tx) for tx in txs]) 49 | await asyncio.wait([self.ledger.wait(tx) for tx in txs]) 50 | 51 | # verify that a previous bug which failed to save TXIs doesn't come back 52 | # this check must happen before generating a new block 53 | self.assertTrue(all([ 54 | tx.inputs[0].txo_ref.txo is not None 55 | for tx in await self.ledger.db.get_transactions(txid__in=[tx.id for tx in txs]) 56 | ])) 57 | 58 | await self.blockchain.generate(1) 59 | await asyncio.wait([self.ledger.wait(tx) for tx in txs]) 60 | await self.assertBalance(self.account, '199.99876') 61 | 62 | # 10 of the UTXOs have been split into a 1 coin UTXO and a 9 UTXO change 63 | self.assertEqual(30, await self.account.get_utxo_count()) 64 | 65 | # spend all 30 UTXOs into a a 199 coin UTXO and change 66 | tx = await self.ledger.transaction_class.create( 67 | [], 68 | [self.ledger.transaction_class.output_class.pay_pubkey_hash( 69 | coins_to_satoshis('199.0'), self.ledger.address_to_hash160(addresses[-1]) 70 | )], 71 | [self.account], self.account 72 | ) 73 | await self.broadcast(tx) 74 | await self.ledger.wait(tx) 75 | await self.blockchain.generate(1) 76 | await self.ledger.wait(tx) 77 | 78 | self.assertEqual(2, await self.account.get_utxo_count()) # 199 + change 79 | await self.assertBalance(self.account, '199.99649') 80 | 81 | async def test_sending_and_receiving(self): 82 | account1, account2 = self.account, self.wallet.generate_account(self.ledger) 83 | await self.ledger.subscribe_account(account2) 84 | 85 | await self.assertBalance(account1, '0.0') 86 | await self.assertBalance(account2, '0.0') 87 | 88 | addresses = await self.account.receiving.get_addresses() 89 | txids = await asyncio.gather(*( 90 | self.blockchain.send_to_address(address, 1.1) for address in addresses[:5] 91 | )) 92 | await asyncio.wait([self.on_transaction_id(txid) for txid in txids]) # mempool 93 | await self.blockchain.generate(1) 94 | await asyncio.wait([self.on_transaction_id(txid) for txid in txids]) # confirmed 95 | await self.assertBalance(account1, '5.5') 96 | await self.assertBalance(account2, '0.0') 97 | 98 | address2 = await account2.receiving.get_or_create_usable_address() 99 | tx = await self.ledger.transaction_class.create( 100 | [], 101 | [self.ledger.transaction_class.output_class.pay_pubkey_hash( 102 | coins_to_satoshis('2.0'), self.ledger.address_to_hash160(address2) 103 | )], 104 | [account1], account1 105 | ) 106 | await self.broadcast(tx) 107 | await self.ledger.wait(tx) # mempool 108 | await self.blockchain.generate(1) 109 | await self.ledger.wait(tx) # confirmed 110 | 111 | await self.assertBalance(account1, '3.499802') 112 | await self.assertBalance(account2, '2.0') 113 | 114 | utxos = await self.account.get_utxos() 115 | tx = await self.ledger.transaction_class.create( 116 | [self.ledger.transaction_class.input_class.spend(utxos[0])], 117 | [], 118 | [account1], account1 119 | ) 120 | await self.broadcast(tx) 121 | await self.ledger.wait(tx) # mempool 122 | await self.blockchain.generate(1) 123 | await self.ledger.wait(tx) # confirmed 124 | 125 | tx = (await account1.get_transactions())[1] 126 | self.assertEqual(satoshis_to_coins(tx.inputs[0].amount), '1.1') 127 | self.assertEqual(satoshis_to_coins(tx.inputs[1].amount), '1.1') 128 | self.assertEqual(satoshis_to_coins(tx.outputs[0].amount), '2.0') 129 | self.assertEqual(tx.outputs[0].get_address(self.ledger), address2) 130 | self.assertEqual(tx.outputs[0].is_change, False) 131 | self.assertEqual(tx.outputs[1].is_change, True) 132 | -------------------------------------------------------------------------------- /tests/client_tests/unit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lbryio/torba/190304344c0ff68f8a24cf50272307a11bf7f62b/tests/client_tests/unit/__init__.py -------------------------------------------------------------------------------- /tests/client_tests/unit/key_fixtures.py: -------------------------------------------------------------------------------- 1 | expected_ids = [ 2 | b'948adae2a128c0bd1fa238117fd0d9690961f26e', 3 | b'cd9f4f2adde7de0a53ab6d326bb6a62b489876dd', 4 | b'c479e02a74a809ffecff60255d1c14f4081a197a', 5 | b'4bab2fb2c424f31f170b15ec53c4a596db9d6710', 6 | b'689cb7c621f57b7c398e7e04ed9a5098ab8389e9', 7 | b'75116d6a689a0f9b56fe7cfec9cbbd0e16814288', 8 | b'2439f0993fb298497dd7f317b9737c356f664a86', 9 | b'32f1cb4799008cf5496bb8cafdaf59d5dabec6af', 10 | b'fa29aa536353904e9cc813b0cf18efcc09e5ad13', 11 | b'37df34002f34d7875428a2977df19be3f4f40a31', 12 | b'8c8a72b5d2747a3e7e05ed85110188769d5656c3', 13 | b'e5c8ef10c5bdaa79c9a237a096f50df4dcac27f0', 14 | b'4d5270dc100fba85974665c20cd0f95d4822e8d1', 15 | b'e76b07da0cdd59915475cd310599544b9744fa34', 16 | b'6f009bccf8be99707161abb279d8ccf8fd953721', 17 | b'f32f08b722cc8607c3f7f192b4d5f13a74c85785', 18 | b'46f4430a5c91b9b799e9be6b47ac7a749d8d9f30', 19 | b'ebbf9850abe0aae2d09e7e3ebd6b51f01282f39b', 20 | b'5f6655438f8ddc6b2f6ea8197c8babaffc9f5c09', 21 | b'e194e70ee8711b0ed765608121e4cceb551cdf28' 22 | ] 23 | expected_privkeys = [ 24 | b'95557ee9a2bb7665e67e45246658b5c839f7dcd99b6ebc800eeebccd28bf134a', 25 | b'689b6921f65647a8e4fc1497924730c92ad4ad183f10fac2bdee65cc8fb6dcf9', 26 | b'977ee018b448c530327b7e927cc3645ca4cb152c5dd98e1bd917c52fd46fc80a', 27 | b'3c7fb05b0ab4da8b292e895f574f8213cadfe81b84ded7423eab61c5f884c8ae', 28 | b'b21fc7be1e69182827538683a48ac9d95684faf6c1c6deabb6e513d8c76afcc9', 29 | b'a5021734dbbf1d090b15509ba00f2c04a3d5afc19939b4594ca0850d4190b923', 30 | b'07dfe0aa94c1b948dc935be1f8179f3050353b46f3a3134e77c70e66208be72d', 31 | b'c331b2fb82cd91120b0703ee312042a854a51a8d945aa9e70fb14d68b0366fe1', 32 | b'3aa59ec4d8f1e7ce2775854b5e82433535b6e3503f9a8e7c4e60aac066d44718', 33 | b'ccc8b4ca73b266b4a0c89a9d33c4ec7532b434c9294c26832355e5e2bee2e005', 34 | b'280c074d8982e56d70c404072252c309694a6e5c05457a6abbe8fc225c2dfd52', 35 | b'546cee26da713a3a64b2066d5e3a52b7c1d927396d1ba8a3d9f6e3e973398856', 36 | b'7fbc4615d5e819eee22db440c5bcc4ff25bb046841c41a192003a6d9abfbafbf', 37 | b'5b63f13011cab965feea3a41fac2d7a877aa710ab20e2a9a1708474e3c05c050', 38 | b'394b36f528947557d317fd40a4adde5514c8745a5f64185421fa2c0c4a158938', 39 | b'8f101c8f5290ae6c0dd76d210b7effacd7f12db18f3befab711f533bde084c76', 40 | b'6637a656f897a66080fbe60027d32c3f4ebc0e3b5f96123a33f932a091b039c2', 41 | b'2815aa6667c042a3a4565fb789890cd33e380d047ed712759d097d479df71051', 42 | b'120e761c6382b07a9548650a20b3b9dd74b906093260fa6f92f790ba71f79e8d', 43 | b'823c8a613ea539f730a968518993195174bf973ed75c734b6898022867165d7b' 44 | ] 45 | expected_hardened_privkeys = [ 46 | b'abdba45b0459e7804beb68edb899e58a5c2636bf67d096711904001406afbd4c', 47 | b'c9e804d4b8fdd99ef6ab2b0ca627a57f4283c28e11e9152ad9d3f863404d940e', 48 | b'4cf87d68ae99711261f8cb8e1bde83b8703ff5d689ef70ce23106d1e6e8ed4bd', 49 | b'dbf8d578c77f9bf62bb2ad40975e253af1e1d44d53abf84a22d2be29b9488f7f', 50 | b'633bb840505521ffe39cb89a04fb8bff3298d6b64a5d8f170aca1e456d6f89b9', 51 | b'92e80a38791bd8ba2105b9867fd58ac2cc4fb9853e18141b7fee1884bc5aae69', 52 | b'd3663339af1386d05dd90ee20f627661ae87ddb1db0c2dc73fd8a4485930d0e7', 53 | b'09a448303452d241b8a25670b36cc758975b97e88f62b6f25cd9084535e3c13a', 54 | b'ee22eb77df05ff53e9c2ba797c1f2ebf97ec4cf5a99528adec94972674aeabed', 55 | b'935facccb6120659c5b7c606a457c797e5a10ce4a728346e1a3a963251169651', 56 | b'8ac9b4a48da1def375640ca03bc6711040dfd4eea7106d42bb4c2de83d7f595e', 57 | b'51ecd3f7565c2b86d5782dbde2175ab26a7b896022564063fafe153588610be9', 58 | b'04918252f6b6f51cd75957289b56a324b45cc085df80839137d740f9ada6c062', 59 | b'2efbd0c839af971e3769c26938d776990ebf097989df4861535a7547a2701483', 60 | b'85c6e31e6b27bd188291a910f4a7faba7fceb3e09df72884b10907ecc1491cd0', 61 | b'05e245131885bebda993a31bb14ac98b794062a50af639ad22010aed1e533a54', 62 | b'ddca42cf7db93f3a3f0723d5fee4c21bf60b7afac35d5c30eb34bd91b35cc609', 63 | b'324a5c16030e0c3947e4dcd2b5057fd3a4d5bed96b23e3b476b2af0ab76369c9', 64 | b'da63c41cdb398cdcd93e832f3e198528afbb4065821b026c143cec910d8362f0' 65 | ] 66 | -------------------------------------------------------------------------------- /tests/client_tests/unit/test_bcd_data_stream.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from torba.client.bcd_data_stream import BCDataStream 4 | 5 | 6 | class TestBCDataStream(unittest.TestCase): 7 | 8 | def test_write_read(self): 9 | s = BCDataStream() 10 | s.write_string(b'a'*252) 11 | s.write_string(b'b'*254) 12 | s.write_string(b'c'*(0xFFFF + 1)) 13 | # s.write_string(b'd'*(0xFFFFFFFF + 1)) 14 | s.write_boolean(True) 15 | s.write_boolean(False) 16 | s.reset() 17 | 18 | self.assertEqual(s.read_string(), b'a'*252) 19 | self.assertEqual(s.read_string(), b'b'*254) 20 | self.assertEqual(s.read_string(), b'c'*(0xFFFF + 1)) 21 | # self.assertEqual(s.read_string(), b'd'*(0xFFFFFFFF + 1)) 22 | self.assertEqual(s.read_boolean(), True) 23 | self.assertEqual(s.read_boolean(), False) 24 | -------------------------------------------------------------------------------- /tests/client_tests/unit/test_bip32.py: -------------------------------------------------------------------------------- 1 | from binascii import unhexlify, hexlify 2 | 3 | from torba.testcase import AsyncioTestCase 4 | 5 | from client_tests.unit.key_fixtures import expected_ids, expected_privkeys, expected_hardened_privkeys 6 | from torba.client.bip32 import PubKey, PrivateKey, from_extended_key_string 7 | from torba.coin.bitcoinsegwit import MainNetLedger as ledger_class 8 | 9 | 10 | class BIP32Tests(AsyncioTestCase): 11 | 12 | def test_pubkey_validation(self): 13 | with self.assertRaisesRegex(TypeError, 'chain code must be raw bytes'): 14 | PubKey(None, None, 1, None, None, None) 15 | with self.assertRaisesRegex(ValueError, 'invalid chain code'): 16 | PubKey(None, None, b'abcd', None, None, None) 17 | with self.assertRaisesRegex(ValueError, 'invalid child number'): 18 | PubKey(None, None, b'abcd'*8, -1, None, None) 19 | with self.assertRaisesRegex(ValueError, 'invalid depth'): 20 | PubKey(None, None, b'abcd'*8, 0, 256, None) 21 | with self.assertRaisesRegex(TypeError, 'pubkey must be raw bytes'): 22 | PubKey(None, None, b'abcd'*8, 0, 255, None) 23 | with self.assertRaisesRegex(ValueError, 'pubkey must be 33 bytes'): 24 | PubKey(None, b'abcd', b'abcd'*8, 0, 255, None) 25 | with self.assertRaisesRegex(ValueError, 'invalid pubkey prefix byte'): 26 | PubKey( 27 | None, 28 | unhexlify('33d1a3dc8155673bc1e2214fa493ccc82d57961b66054af9b6b653ac28eeef3ffe'), 29 | b'abcd'*8, 0, 255, None 30 | ) 31 | pubkey = PubKey( # success 32 | None, 33 | unhexlify('03d1a3dc8155673bc1e2214fa493ccc82d57961b66054af9b6b653ac28eeef3ffe'), 34 | b'abcd'*8, 0, 1, None 35 | ) 36 | with self.assertRaisesRegex(ValueError, 'invalid BIP32 public key child number'): 37 | pubkey.child(-1) 38 | for i in range(20): 39 | new_key = pubkey.child(i) 40 | self.assertIsInstance(new_key, PubKey) 41 | self.assertEqual(hexlify(new_key.identifier()), expected_ids[i]) 42 | 43 | async def test_private_key_validation(self): 44 | with self.assertRaisesRegex(TypeError, 'private key must be raw bytes'): 45 | PrivateKey(None, None, b'abcd'*8, 0, 255) 46 | with self.assertRaisesRegex(ValueError, 'private key must be 32 bytes'): 47 | PrivateKey(None, b'abcd', b'abcd'*8, 0, 255) 48 | private_key = PrivateKey( 49 | ledger_class({ 50 | 'db': ledger_class.database_class(':memory:'), 51 | 'headers': ledger_class.headers_class(':memory:'), 52 | }), 53 | unhexlify('2423f3dc6087d9683f73a684935abc0ccd8bc26370588f56653128c6a6f0bf7c'), 54 | b'abcd'*8, 0, 1 55 | ) 56 | ec_point = private_key.ec_point() 57 | self.assertEqual( 58 | ec_point[0], 30487144161998778625547553412379759661411261804838752332906558028921886299019 59 | ) 60 | self.assertEqual( 61 | ec_point[1], 86198965946979720220333266272536217633917099472454294641561154971209433250106 62 | ) 63 | self.assertEqual(private_key.address(), '1GVM5dEhThbiyCZ9gqBZBv6p9whga7MTXo' ) 64 | with self.assertRaisesRegex(ValueError, 'invalid BIP32 private key child number'): 65 | private_key.child(-1) 66 | self.assertIsInstance(private_key.child(PrivateKey.HARDENED), PrivateKey) 67 | 68 | async def test_private_key_derivation(self): 69 | private_key = PrivateKey( 70 | ledger_class({ 71 | 'db': ledger_class.database_class(':memory:'), 72 | 'headers': ledger_class.headers_class(':memory:'), 73 | }), 74 | unhexlify('2423f3dc6087d9683f73a684935abc0ccd8bc26370588f56653128c6a6f0bf7c'), 75 | b'abcd'*8, 0, 1 76 | ) 77 | for i in range(20): 78 | new_privkey = private_key.child(i) 79 | self.assertIsInstance(new_privkey, PrivateKey) 80 | self.assertEqual(hexlify(new_privkey.private_key_bytes), expected_privkeys[i]) 81 | for i in range(PrivateKey.HARDENED + 1, private_key.HARDENED + 20): 82 | new_privkey = private_key.child(i) 83 | self.assertIsInstance(new_privkey, PrivateKey) 84 | self.assertEqual(hexlify(new_privkey.private_key_bytes), expected_hardened_privkeys[i - 1 - PrivateKey.HARDENED]) 85 | 86 | async def test_from_extended_keys(self): 87 | ledger = ledger_class({ 88 | 'db': ledger_class.database_class(':memory:'), 89 | 'headers': ledger_class.headers_class(':memory:'), 90 | }) 91 | self.assertIsInstance( 92 | from_extended_key_string( 93 | ledger, 94 | 'xprv9s21ZrQH143K2dyhK7SevfRG72bYDRNv25yKPWWm6dqApNxm1Zb1m5gGcBWYfbsPjTr2v5joit8Af2Zp5P' 95 | '6yz3jMbycrLrRMpeAJxR8qDg8', 96 | ), PrivateKey 97 | ) 98 | self.assertIsInstance( 99 | from_extended_key_string( 100 | ledger, 101 | 'xpub661MyMwAqRbcF84AR8yfHoMzf4S2ct6mPJtvBtvNeyN9hBHuZ6uGJszkTSn5fQUCdz3XU17eBzFeAUwV6f' 102 | 'iW44g14WF52fYC5J483wqQ5ZP', 103 | ), PubKey 104 | ) 105 | -------------------------------------------------------------------------------- /tests/client_tests/unit/test_coinselection.py: -------------------------------------------------------------------------------- 1 | from types import GeneratorType 2 | 3 | from torba.testcase import AsyncioTestCase 4 | 5 | from torba.coin.bitcoinsegwit import MainNetLedger as ledger_class 6 | from torba.client.coinselection import CoinSelector, MAXIMUM_TRIES 7 | from torba.client.constants import CENT 8 | 9 | from client_tests.unit.test_transaction import get_output as utxo 10 | 11 | 12 | NULL_HASH = b'\x00'*32 13 | 14 | 15 | def search(*args, **kwargs): 16 | selection = CoinSelector(*args, **kwargs).branch_and_bound() 17 | return [o.txo.amount for o in selection] if selection else selection 18 | 19 | 20 | class BaseSelectionTestCase(AsyncioTestCase): 21 | 22 | async def asyncSetUp(self): 23 | self.ledger = ledger_class({ 24 | 'db': ledger_class.database_class(':memory:'), 25 | 'headers': ledger_class.headers_class(':memory:'), 26 | }) 27 | await self.ledger.db.open() 28 | 29 | async def asyncTearDown(self): 30 | await self.ledger.db.close() 31 | 32 | def estimates(self, *args): 33 | txos = args[0] if isinstance(args[0], (GeneratorType, list)) else args 34 | return [txo.get_estimator(self.ledger) for txo in txos] 35 | 36 | 37 | class TestCoinSelectionTests(BaseSelectionTestCase): 38 | 39 | def test_empty_coins(self): 40 | self.assertEqual(CoinSelector([], 0, 0).select(), []) 41 | 42 | def test_skip_binary_search_if_total_not_enough(self): 43 | fee = utxo(CENT).get_estimator(self.ledger).fee 44 | big_pool = self.estimates(utxo(CENT+fee) for _ in range(100)) 45 | selector = CoinSelector(big_pool, 101 * CENT, 0) 46 | self.assertEqual(selector.select(), []) 47 | self.assertEqual(selector.tries, 0) # Never tried. 48 | # check happy path 49 | selector = CoinSelector(big_pool, 100 * CENT, 0) 50 | self.assertEqual(len(selector.select()), 100) 51 | self.assertEqual(selector.tries, 201) 52 | 53 | def test_exact_match(self): 54 | fee = utxo(CENT).get_estimator(self.ledger).fee 55 | utxo_pool = self.estimates( 56 | utxo(CENT + fee), 57 | utxo(CENT), 58 | utxo(CENT - fee) 59 | ) 60 | selector = CoinSelector(utxo_pool, CENT, 0) 61 | match = selector.select() 62 | self.assertEqual([CENT + fee], [c.txo.amount for c in match]) 63 | self.assertTrue(selector.exact_match) 64 | 65 | def test_random_draw(self): 66 | utxo_pool = self.estimates( 67 | utxo(2 * CENT), 68 | utxo(3 * CENT), 69 | utxo(4 * CENT) 70 | ) 71 | selector = CoinSelector(utxo_pool, CENT, 0, '\x00') 72 | match = selector.select() 73 | self.assertEqual([2 * CENT], [c.txo.amount for c in match]) 74 | self.assertFalse(selector.exact_match) 75 | 76 | def test_pick(self): 77 | utxo_pool = self.estimates( 78 | utxo(1*CENT), 79 | utxo(1*CENT), 80 | utxo(3*CENT), 81 | utxo(5*CENT), 82 | utxo(10*CENT), 83 | ) 84 | selector = CoinSelector(utxo_pool, 3*CENT, 0) 85 | match = selector.select() 86 | self.assertEqual([5*CENT], [c.txo.amount for c in match]) 87 | 88 | def test_prefer_confirmed_strategy(self): 89 | utxo_pool = self.estimates( 90 | utxo(11*CENT, height=5), 91 | utxo(11*CENT, height=0), 92 | utxo(11*CENT, height=-2), 93 | utxo(11*CENT, height=5), 94 | ) 95 | selector = CoinSelector(utxo_pool, 20*CENT, 0) 96 | match = selector.select("prefer_confirmed") 97 | self.assertEqual([5, 5], [c.txo.tx_ref.height for c in match]) 98 | 99 | 100 | class TestOfficialBitcoinCoinSelectionTests(BaseSelectionTestCase): 101 | 102 | # Bitcoin implementation: 103 | # https://github.com/bitcoin/bitcoin/blob/master/src/wallet/coinselection.cpp 104 | # 105 | # Bitcoin implementation tests: 106 | # https://github.com/bitcoin/bitcoin/blob/master/src/wallet/test/coinselector_tests.cpp 107 | # 108 | # Branch and Bound coin selection white paper: 109 | # https://murch.one/wp-content/uploads/2016/11/erhardt2016coinselection.pdf 110 | 111 | def make_hard_case(self, utxos): 112 | target = 0 113 | utxo_pool = [] 114 | for i in range(utxos): 115 | amount = 1 << (utxos+i) 116 | target += amount 117 | utxo_pool.append(utxo(amount)) 118 | utxo_pool.append(utxo(amount + (1 << (utxos-1-i)))) 119 | return self.estimates(utxo_pool), target 120 | 121 | def test_branch_and_bound_coin_selection(self): 122 | self.ledger.fee_per_byte = 0 123 | 124 | utxo_pool = self.estimates( 125 | utxo(1 * CENT), 126 | utxo(2 * CENT), 127 | utxo(3 * CENT), 128 | utxo(4 * CENT) 129 | ) 130 | 131 | # Select 1 Cent 132 | self.assertEqual([1 * CENT], search(utxo_pool, 1 * CENT, 0.5 * CENT)) 133 | 134 | # Select 2 Cent 135 | self.assertEqual([2 * CENT], search(utxo_pool, 2 * CENT, 0.5 * CENT)) 136 | 137 | # Select 5 Cent 138 | self.assertEqual([3 * CENT, 2 * CENT], search(utxo_pool, 5 * CENT, 0.5 * CENT)) 139 | 140 | # Select 11 Cent, not possible 141 | self.assertEqual([], search(utxo_pool, 11 * CENT, 0.5 * CENT)) 142 | 143 | # Select 10 Cent 144 | utxo_pool += self.estimates(utxo(5 * CENT)) 145 | self.assertEqual( 146 | [4 * CENT, 3 * CENT, 2 * CENT, 1 * CENT], 147 | search(utxo_pool, 10 * CENT, 0.5 * CENT) 148 | ) 149 | 150 | # Negative effective value 151 | # Select 10 Cent but have 1 Cent not be possible because too small 152 | # TODO: bitcoin has [5, 3, 2] 153 | self.assertEqual( 154 | [4 * CENT, 3 * CENT, 2 * CENT, 1 * CENT], 155 | search(utxo_pool, 10 * CENT, 5000) 156 | ) 157 | 158 | # Select 0.25 Cent, not possible 159 | self.assertEqual(search(utxo_pool, 0.25 * CENT, 0.5 * CENT), []) 160 | 161 | # Iteration exhaustion test 162 | utxo_pool, target = self.make_hard_case(17) 163 | selector = CoinSelector(utxo_pool, target, 0) 164 | self.assertEqual(selector.branch_and_bound(), []) 165 | self.assertEqual(selector.tries, MAXIMUM_TRIES) # Should exhaust 166 | utxo_pool, target = self.make_hard_case(14) 167 | self.assertIsNotNone(search(utxo_pool, target, 0)) # Should not exhaust 168 | 169 | # Test same value early bailout optimization 170 | utxo_pool = self.estimates([ 171 | utxo(7 * CENT), 172 | utxo(7 * CENT), 173 | utxo(7 * CENT), 174 | utxo(7 * CENT), 175 | utxo(2 * CENT) 176 | ] + [utxo(5 * CENT)]*50000) 177 | self.assertEqual( 178 | [7 * CENT, 7 * CENT, 7 * CENT, 7 * CENT, 2 * CENT], 179 | search(utxo_pool, 30 * CENT, 5000) 180 | ) 181 | 182 | # Select 1 Cent with pool of only greater than 5 Cent 183 | utxo_pool = self.estimates(utxo(i * CENT) for i in range(5, 21)) 184 | for _ in range(100): 185 | self.assertEqual(search(utxo_pool, 1 * CENT, 2 * CENT), []) 186 | -------------------------------------------------------------------------------- /tests/client_tests/unit/test_hash.py: -------------------------------------------------------------------------------- 1 | from unittest import TestCase, mock 2 | from torba.client.hash import aes_decrypt, aes_encrypt, better_aes_decrypt, better_aes_encrypt 3 | 4 | 5 | class TestAESEncryptDecrypt(TestCase): 6 | message = 'The Times 03/Jan/2009 Chancellor on brink of second bailout for banks' 7 | expected = 'ZmZmZmZmZmZmZmZmZmZmZjlrKptoKD+MFwDxcg3XtCD9qz8UWhEhq/TVJT5+Mtp2a8sE' \ 8 | 'CaO6WQj7fYsWGu2Hvbc0qYqxdN0HeTsiO+cZRo3eJISgr3F+rXFYi5oSBlD2' 9 | password = 'bubblegum' 10 | 11 | @mock.patch('os.urandom', side_effect=lambda i: b'd'*i) 12 | def test_encrypt_iv_f(self, _): 13 | self.assertEqual( 14 | aes_encrypt(self.password, self.message), 15 | 'ZGRkZGRkZGRkZGRkZGRkZKBP/4pR+47hLHbHyvDJm9aRKDuoBdTG8SrFvHqfagK6Co1VrHUOd' 16 | 'oF+6PGSxru3+VR63ybkXLNM75s/qVw+dnKVAkI8OfoVnJvGRSc49e38' 17 | ) 18 | 19 | @mock.patch('os.urandom', side_effect=lambda i: b'f'*i) 20 | def test_encrypt_iv_d(self, _): 21 | self.assertEqual( 22 | aes_encrypt(self.password, self.message), 23 | 'ZmZmZmZmZmZmZmZmZmZmZjlrKptoKD+MFwDxcg3XtCD9qz8UWhEhq/TVJT5+Mtp2a8sE' 24 | 'CaO6WQj7fYsWGu2Hvbc0qYqxdN0HeTsiO+cZRo3eJISgr3F+rXFYi5oSBlD2' 25 | ) 26 | self.assertEqual( 27 | aes_decrypt(self.password, self.expected), 28 | (self.message, b'f' * 16) 29 | ) 30 | 31 | def test_encrypt_decrypt(self): 32 | self.assertEqual( 33 | aes_decrypt('bubblegum', aes_encrypt('bubblegum', self.message))[0], 34 | self.message 35 | ) 36 | 37 | def test_better_encrypt_decrypt(self): 38 | self.assertEqual( 39 | b'valuable value', 40 | better_aes_decrypt( 41 | 'super secret', 42 | better_aes_encrypt('super secret', b'valuable value'))) 43 | -------------------------------------------------------------------------------- /tests/client_tests/unit/test_headers.py: -------------------------------------------------------------------------------- 1 | import os 2 | from urllib.request import Request, urlopen 3 | 4 | from torba.testcase import AsyncioTestCase 5 | 6 | from torba.coin.bitcoinsegwit import MainHeaders 7 | 8 | 9 | def block_bytes(blocks): 10 | return blocks * MainHeaders.header_size 11 | 12 | 13 | class BitcoinHeadersTestCase(AsyncioTestCase): 14 | 15 | # Download headers instead of storing them in git. 16 | HEADER_URL = 'http://headers.electrum.org/blockchain_headers' 17 | HEADER_FILE = 'bitcoin_headers' 18 | HEADER_BYTES = block_bytes(32260) # 2.6MB 19 | RETARGET_BLOCK = 32256 # difficulty: 1 -> 1.18 20 | 21 | def setUp(self): 22 | self.maxDiff = None 23 | self.header_file_name = os.path.join(os.path.dirname(__file__), self.HEADER_FILE) 24 | if not os.path.exists(self.header_file_name): 25 | req = Request(self.HEADER_URL) 26 | req.add_header('Range', 'bytes=0-{}'.format(self.HEADER_BYTES-1)) 27 | with urlopen(req) as response, open(self.header_file_name, 'wb') as header_file: 28 | header_file.write(response.read()) 29 | if os.path.getsize(self.header_file_name) != self.HEADER_BYTES: 30 | os.remove(self.header_file_name) 31 | raise Exception( 32 | "Downloaded headers for testing are not the correct number of bytes. " 33 | "They were deleted. Try running the tests again." 34 | ) 35 | 36 | def get_bytes(self, upto: int = -1, after: int = 0) -> bytes: 37 | with open(self.header_file_name, 'rb') as headers: 38 | headers.seek(after, os.SEEK_SET) 39 | return headers.read(upto) 40 | 41 | async def get_headers(self, upto: int = -1): 42 | h = MainHeaders(':memory:') 43 | h.io.write(self.get_bytes(upto)) 44 | return h 45 | 46 | 47 | class BasicHeadersTests(BitcoinHeadersTestCase): 48 | 49 | async def test_serialization(self): 50 | h = await self.get_headers() 51 | self.assertEqual(h[0], { 52 | 'bits': 486604799, 53 | 'block_height': 0, 54 | 'merkle_root': b'4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b', 55 | 'nonce': 2083236893, 56 | 'prev_block_hash': b'0000000000000000000000000000000000000000000000000000000000000000', 57 | 'timestamp': 1231006505, 58 | 'version': 1 59 | }) 60 | self.assertEqual(h[self.RETARGET_BLOCK-1], { 61 | 'bits': 486604799, 62 | 'block_height': 32255, 63 | 'merkle_root': b'89b4f223789e40b5b475af6483bb05bceda54059e17d2053334b358f6bb310ac', 64 | 'nonce': 312762301, 65 | 'prev_block_hash': b'000000006baebaa74cecde6c6787c26ee0a616a3c333261bff36653babdac149', 66 | 'timestamp': 1262152739, 67 | 'version': 1 68 | }) 69 | self.assertEqual(h[self.RETARGET_BLOCK], { 70 | 'bits': 486594666, 71 | 'block_height': 32256, 72 | 'merkle_root': b'64b5e5f5a262f47af443a0120609206a3305877693edfe03e994f20a024ab627', 73 | 'nonce': 121087187, 74 | 'prev_block_hash': b'00000000984f962134a7291e3693075ae03e521f0ee33378ec30a334d860034b', 75 | 'timestamp': 1262153464, 76 | 'version': 1 77 | }) 78 | self.assertEqual(h[self.RETARGET_BLOCK+1], { 79 | 'bits': 486594666, 80 | 'block_height': 32257, 81 | 'merkle_root': b'4d1488981f08b3037878193297dbac701a2054e0f803d4424fe6a4d763d62334', 82 | 'nonce': 274675219, 83 | 'prev_block_hash': b'000000004f2886a170adb7204cb0c7a824217dd24d11a74423d564c4e0904967', 84 | 'timestamp': 1262154352, 85 | 'version': 1 86 | }) 87 | self.assertEqual( 88 | h.serialize(h[0]), 89 | h.get_raw_header(0) 90 | ) 91 | self.assertEqual( 92 | h.serialize(h[self.RETARGET_BLOCK]), 93 | h.get_raw_header(self.RETARGET_BLOCK) 94 | ) 95 | 96 | async def test_connect_from_genesis_to_3000_past_first_chunk_at_2016(self): 97 | headers = MainHeaders(':memory:') 98 | self.assertEqual(headers.height, -1) 99 | await headers.connect(0, self.get_bytes(block_bytes(3001))) 100 | self.assertEqual(headers.height, 3000) 101 | 102 | async def test_connect_9_blocks_passing_a_retarget_at_32256(self): 103 | retarget = block_bytes(self.RETARGET_BLOCK-5) 104 | headers = await self.get_headers(upto=retarget) 105 | remainder = self.get_bytes(after=retarget) 106 | self.assertEqual(headers.height, 32250) 107 | await headers.connect(len(headers), remainder) 108 | self.assertEqual(headers.height, 32259) 109 | -------------------------------------------------------------------------------- /tests/client_tests/unit/test_ledger.py: -------------------------------------------------------------------------------- 1 | import os 2 | from binascii import hexlify 3 | 4 | from torba.coin.bitcoinsegwit import MainNetLedger 5 | from torba.client.wallet import Wallet 6 | 7 | from client_tests.unit.test_transaction import get_transaction, get_output 8 | from client_tests.unit.test_headers import BitcoinHeadersTestCase, block_bytes 9 | 10 | 11 | class MockNetwork: 12 | 13 | def __init__(self, history, transaction): 14 | self.history = history 15 | self.transaction = transaction 16 | self.address = None 17 | self.get_history_called = [] 18 | self.get_transaction_called = [] 19 | self.is_connected = False 20 | 21 | async def get_history(self, address): 22 | self.get_history_called.append(address) 23 | self.address = address 24 | return self.history 25 | 26 | async def get_merkle(self, txid, height): 27 | return {'merkle': ['abcd01'], 'pos': 1} 28 | 29 | async def get_transaction(self, tx_hash): 30 | self.get_transaction_called.append(tx_hash) 31 | return self.transaction[tx_hash] 32 | 33 | 34 | class LedgerTestCase(BitcoinHeadersTestCase): 35 | 36 | async def asyncSetUp(self): 37 | self.ledger = MainNetLedger({ 38 | 'db': MainNetLedger.database_class(':memory:'), 39 | 'headers': MainNetLedger.headers_class(':memory:') 40 | }) 41 | await self.ledger.db.open() 42 | 43 | async def asyncTearDown(self): 44 | await self.ledger.db.close() 45 | 46 | def make_header(self, **kwargs): 47 | header = { 48 | 'bits': 486604799, 49 | 'block_height': 0, 50 | 'merkle_root': b'4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b', 51 | 'nonce': 2083236893, 52 | 'prev_block_hash': b'0000000000000000000000000000000000000000000000000000000000000000', 53 | 'timestamp': 1231006505, 54 | 'version': 1 55 | } 56 | header.update(kwargs) 57 | header['merkle_root'] = header['merkle_root'].ljust(64, b'a') 58 | header['prev_block_hash'] = header['prev_block_hash'].ljust(64, b'0') 59 | return self.ledger.headers.serialize(header) 60 | 61 | def add_header(self, **kwargs): 62 | serialized = self.make_header(**kwargs) 63 | self.ledger.headers.io.seek(0, os.SEEK_END) 64 | self.ledger.headers.io.write(serialized) 65 | self.ledger.headers._size = None 66 | 67 | 68 | class TestSynchronization(LedgerTestCase): 69 | 70 | async def test_update_history(self): 71 | account = self.ledger.account_class.generate(self.ledger, Wallet(), "torba") 72 | address = await account.receiving.get_or_create_usable_address() 73 | address_details = await self.ledger.db.get_address(address=address) 74 | self.assertEqual(address_details['history'], None) 75 | 76 | self.add_header(block_height=0, merkle_root=b'abcd04') 77 | self.add_header(block_height=1, merkle_root=b'abcd04') 78 | self.add_header(block_height=2, merkle_root=b'abcd04') 79 | self.add_header(block_height=3, merkle_root=b'abcd04') 80 | self.ledger.network = MockNetwork([ 81 | {'tx_hash': 'abcd01', 'height': 0}, 82 | {'tx_hash': 'abcd02', 'height': 1}, 83 | {'tx_hash': 'abcd03', 'height': 2}, 84 | ], { 85 | 'abcd01': hexlify(get_transaction(get_output(1)).raw), 86 | 'abcd02': hexlify(get_transaction(get_output(2)).raw), 87 | 'abcd03': hexlify(get_transaction(get_output(3)).raw), 88 | }) 89 | await self.ledger.update_history(address, '') 90 | self.assertEqual(self.ledger.network.get_history_called, [address]) 91 | self.assertEqual(self.ledger.network.get_transaction_called, ['abcd01', 'abcd02', 'abcd03']) 92 | 93 | address_details = await self.ledger.db.get_address(address=address) 94 | self.assertEqual( 95 | address_details['history'], 96 | '252bda9b22cc902ca2aa2de3548ee8baf06b8501ff7bfb3b0b7d980dbd1bf792:0:' 97 | 'ab9c0654dd484ac20437030f2034e25dcb29fc507e84b91138f80adc3af738f9:1:' 98 | 'a2ae3d1db3c727e7d696122cab39ee20a7f81856dab7019056dd539f38c548a0:2:' 99 | ) 100 | 101 | self.ledger.network.get_history_called = [] 102 | self.ledger.network.get_transaction_called = [] 103 | await self.ledger.update_history(address, '') 104 | self.assertEqual(self.ledger.network.get_history_called, [address]) 105 | self.assertEqual(self.ledger.network.get_transaction_called, []) 106 | 107 | self.ledger.network.history.append({'tx_hash': 'abcd04', 'height': 3}) 108 | self.ledger.network.transaction['abcd04'] = hexlify(get_transaction(get_output(4)).raw) 109 | self.ledger.network.get_history_called = [] 110 | self.ledger.network.get_transaction_called = [] 111 | await self.ledger.update_history(address, '') 112 | self.assertEqual(self.ledger.network.get_history_called, [address]) 113 | self.assertEqual(self.ledger.network.get_transaction_called, ['abcd04']) 114 | address_details = await self.ledger.db.get_address(address=address) 115 | self.assertEqual( 116 | address_details['history'], 117 | '252bda9b22cc902ca2aa2de3548ee8baf06b8501ff7bfb3b0b7d980dbd1bf792:0:' 118 | 'ab9c0654dd484ac20437030f2034e25dcb29fc507e84b91138f80adc3af738f9:1:' 119 | 'a2ae3d1db3c727e7d696122cab39ee20a7f81856dab7019056dd539f38c548a0:2:' 120 | '047cf1d53ef68f0fd586d46f90c09ff8e57a4180f67e7f4b8dd0135c3741e828:3:' 121 | ) 122 | 123 | 124 | class MocHeaderNetwork: 125 | def __init__(self, responses): 126 | self.responses = responses 127 | 128 | async def get_headers(self, height, blocks): 129 | return self.responses[height] 130 | 131 | 132 | class BlockchainReorganizationTests(LedgerTestCase): 133 | 134 | async def test_1_block_reorganization(self): 135 | self.ledger.network = MocHeaderNetwork({ 136 | 20: {'height': 20, 'count': 5, 'hex': hexlify( 137 | self.get_bytes(after=block_bytes(20), upto=block_bytes(5)) 138 | )}, 139 | 25: {'height': 25, 'count': 0, 'hex': b''} 140 | }) 141 | headers = self.ledger.headers 142 | await headers.connect(0, self.get_bytes(upto=block_bytes(20))) 143 | self.add_header(block_height=len(headers)) 144 | self.assertEqual(headers.height, 20) 145 | await self.ledger.receive_header([{ 146 | 'height': 21, 'hex': hexlify(self.make_header(block_height=21)) 147 | }]) 148 | 149 | async def test_3_block_reorganization(self): 150 | self.ledger.network = MocHeaderNetwork({ 151 | 20: {'height': 20, 'count': 5, 'hex': hexlify( 152 | self.get_bytes(after=block_bytes(20), upto=block_bytes(5)) 153 | )}, 154 | 21: {'height': 21, 'count': 1, 'hex': hexlify(self.make_header(block_height=21))}, 155 | 22: {'height': 22, 'count': 1, 'hex': hexlify(self.make_header(block_height=22))}, 156 | 25: {'height': 25, 'count': 0, 'hex': b''} 157 | }) 158 | headers = self.ledger.headers 159 | await headers.connect(0, self.get_bytes(upto=block_bytes(20))) 160 | self.add_header(block_height=len(headers)) 161 | self.add_header(block_height=len(headers)) 162 | self.add_header(block_height=len(headers)) 163 | self.assertEqual(headers.height, 22) 164 | await self.ledger.receive_header(({ 165 | 'height': 23, 'hex': hexlify(self.make_header(block_height=23)) 166 | },)) 167 | -------------------------------------------------------------------------------- /tests/client_tests/unit/test_mnemonic.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from binascii import hexlify 3 | 4 | from torba.client.mnemonic import Mnemonic 5 | 6 | 7 | class TestMnemonic(unittest.TestCase): 8 | 9 | def test_mnemonic_to_seed(self): 10 | seed = Mnemonic.mnemonic_to_seed(mnemonic=u'foobar', passphrase=u'torba') 11 | self.assertEqual( 12 | hexlify(seed), 13 | b'475a419db4e991cab14f08bde2d357e52b3e7241f72c6d8a2f92782367feeee9f403dc6a37c26a3f02ab9' 14 | b'dec7f5063161eb139cea00da64cd77fba2f07c49ddc' 15 | ) 16 | 17 | def test_make_seed_decode_encode(self): 18 | iters = 10 19 | m = Mnemonic('en') 20 | for _ in range(iters): 21 | seed = m.make_seed() 22 | i = m.mnemonic_decode(seed) 23 | self.assertEqual(m.mnemonic_encode(i), seed) 24 | -------------------------------------------------------------------------------- /tests/client_tests/unit/test_script.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from binascii import hexlify, unhexlify 3 | 4 | from torba.client.bcd_data_stream import BCDataStream 5 | from torba.client.basescript import Template, ParseError, tokenize, push_data 6 | from torba.client.basescript import PUSH_SINGLE, PUSH_INTEGER, PUSH_MANY, OP_HASH160, OP_EQUAL 7 | from torba.client.basescript import BaseInputScript, BaseOutputScript 8 | 9 | 10 | def parse(opcodes, source): 11 | template = Template('test', opcodes) 12 | s = BCDataStream() 13 | for t in source: 14 | if isinstance(t, bytes): 15 | s.write_many(push_data(t)) 16 | elif isinstance(t, int): 17 | s.write_uint8(t) 18 | else: 19 | raise ValueError() 20 | s.reset() 21 | return template.parse(tokenize(s)) 22 | 23 | 24 | class TestScriptTemplates(unittest.TestCase): 25 | 26 | def test_push_data(self): 27 | self.assertEqual(parse( 28 | (PUSH_SINGLE('script_hash'),), 29 | (b'abcdef',) 30 | ), { 31 | 'script_hash': b'abcdef' 32 | } 33 | ) 34 | self.assertEqual(parse( 35 | (PUSH_SINGLE('first'), PUSH_INTEGER('rating')), 36 | (b'Satoshi', (1000).to_bytes(2, 'little')) 37 | ), { 38 | 'first': b'Satoshi', 39 | 'rating': 1000, 40 | } 41 | ) 42 | self.assertEqual(parse( 43 | (OP_HASH160, PUSH_SINGLE('script_hash'), OP_EQUAL), 44 | (OP_HASH160, b'abcdef', OP_EQUAL) 45 | ), { 46 | 'script_hash': b'abcdef' 47 | } 48 | ) 49 | 50 | def test_push_data_many(self): 51 | self.assertEqual(parse( 52 | (PUSH_MANY('names'),), 53 | (b'amit',) 54 | ), { 55 | 'names': [b'amit'] 56 | } 57 | ) 58 | self.assertEqual(parse( 59 | (PUSH_MANY('names'),), 60 | (b'jeremy', b'amit', b'victor') 61 | ), { 62 | 'names': [b'jeremy', b'amit', b'victor'] 63 | } 64 | ) 65 | self.assertEqual(parse( 66 | (OP_HASH160, PUSH_MANY('names'), OP_EQUAL), 67 | (OP_HASH160, b'grin', b'jack', OP_EQUAL) 68 | ), { 69 | 'names': [b'grin', b'jack'] 70 | } 71 | ) 72 | 73 | def test_push_data_mixed(self): 74 | self.assertEqual(parse( 75 | (PUSH_SINGLE('CEO'), PUSH_MANY('Devs'), PUSH_SINGLE('CTO'), PUSH_SINGLE('State')), 76 | (b'jeremy', b'lex', b'amit', b'victor', b'jack', b'grin', b'NH') 77 | ), { 78 | 'CEO': b'jeremy', 79 | 'CTO': b'grin', 80 | 'Devs': [b'lex', b'amit', b'victor', b'jack'], 81 | 'State': b'NH' 82 | } 83 | ) 84 | 85 | def test_push_data_many_separated(self): 86 | self.assertEqual(parse( 87 | (PUSH_MANY('Chiefs'), OP_HASH160, PUSH_MANY('Devs')), 88 | (b'jeremy', b'grin', OP_HASH160, b'lex', b'jack') 89 | ), { 90 | 'Chiefs': [b'jeremy', b'grin'], 91 | 'Devs': [b'lex', b'jack'] 92 | } 93 | ) 94 | 95 | def test_push_data_many_not_separated(self): 96 | with self.assertRaisesRegex(ParseError, 'consecutive PUSH_MANY'): 97 | parse((PUSH_MANY('Chiefs'), PUSH_MANY('Devs')), (b'jeremy', b'grin', b'lex', b'jack')) 98 | 99 | 100 | class TestRedeemPubKeyHash(unittest.TestCase): 101 | 102 | def redeem_pubkey_hash(self, sig, pubkey): 103 | # this checks that factory function correctly sets up the script 104 | src1 = BaseInputScript.redeem_pubkey_hash(unhexlify(sig), unhexlify(pubkey)) 105 | self.assertEqual(src1.template.name, 'pubkey_hash') 106 | self.assertEqual(hexlify(src1.values['signature']), sig) 107 | self.assertEqual(hexlify(src1.values['pubkey']), pubkey) 108 | # now we test that it will round trip 109 | src2 = BaseInputScript(src1.source) 110 | self.assertEqual(src2.template.name, 'pubkey_hash') 111 | self.assertEqual(hexlify(src2.values['signature']), sig) 112 | self.assertEqual(hexlify(src2.values['pubkey']), pubkey) 113 | return hexlify(src1.source) 114 | 115 | def test_redeem_pubkey_hash_1(self): 116 | self.assertEqual( 117 | self.redeem_pubkey_hash( 118 | b'30450221009dc93f25184a8d483745cd3eceff49727a317c9bfd8be8d3d04517e9cdaf8dd502200e' 119 | b'02dc5939cad9562d2b1f303f185957581c4851c98d497af281118825e18a8301', 120 | b'025415a06514230521bff3aaface31f6db9d9bbc39bf1ca60a189e78731cfd4e1b' 121 | ), 122 | b'4830450221009dc93f25184a8d483745cd3eceff49727a317c9bfd8be8d3d04517e9cdaf8dd502200e02d' 123 | b'c5939cad9562d2b1f303f185957581c4851c98d497af281118825e18a830121025415a06514230521bff3' 124 | b'aaface31f6db9d9bbc39bf1ca60a189e78731cfd4e1b' 125 | ) 126 | 127 | 128 | class TestRedeemScriptHash(unittest.TestCase): 129 | 130 | def redeem_script_hash(self, sigs, pubkeys): 131 | # this checks that factory function correctly sets up the script 132 | src1 = BaseInputScript.redeem_script_hash( 133 | [unhexlify(sig) for sig in sigs], 134 | [unhexlify(pubkey) for pubkey in pubkeys] 135 | ) 136 | subscript1 = src1.values['script'] 137 | self.assertEqual(src1.template.name, 'script_hash') 138 | self.assertEqual([hexlify(v) for v in src1.values['signatures']], sigs) 139 | self.assertEqual([hexlify(p) for p in subscript1.values['pubkeys']], pubkeys) 140 | self.assertEqual(subscript1.values['signatures_count'], len(sigs)) 141 | self.assertEqual(subscript1.values['pubkeys_count'], len(pubkeys)) 142 | # now we test that it will round trip 143 | src2 = BaseInputScript(src1.source) 144 | subscript2 = src2.values['script'] 145 | self.assertEqual(src2.template.name, 'script_hash') 146 | self.assertEqual([hexlify(v) for v in src2.values['signatures']], sigs) 147 | self.assertEqual([hexlify(p) for p in subscript2.values['pubkeys']], pubkeys) 148 | self.assertEqual(subscript2.values['signatures_count'], len(sigs)) 149 | self.assertEqual(subscript2.values['pubkeys_count'], len(pubkeys)) 150 | return hexlify(src1.source) 151 | 152 | def test_redeem_script_hash_1(self): 153 | self.assertEqual( 154 | self.redeem_script_hash([ 155 | b'3045022100fec82ed82687874f2a29cbdc8334e114af645c45298e85bb1efe69fcf15c617a0220575' 156 | b'e40399f9ada388d8e522899f4ec3b7256896dd9b02742f6567d960b613f0401', 157 | b'3044022024890462f731bd1a42a4716797bad94761fc4112e359117e591c07b8520ea33b02201ac68' 158 | b'9e35c4648e6beff1d42490207ba14027a638a62663b2ee40153299141eb01', 159 | b'30450221009910823e0142967a73c2d16c1560054d71c0625a385904ba2f1f53e0bc1daa8d02205cd' 160 | b'70a89c6cf031a8b07d1d5eb0d65d108c4d49c2d403f84fb03ad3dc318777a01' 161 | ], [ 162 | b'0372ba1fd35e5f1b1437cba0c4ebfc4025b7349366f9f9c7c8c4b03a47bd3f68a4', 163 | b'03061d250182b2db1ba144167fd8b0ef3fe0fc3a2fa046958f835ffaf0dfdb7692', 164 | b'02463bfbc1eaec74b5c21c09239ae18dbf6fc07833917df10d0b43e322810cee0c', 165 | b'02fa6a6455c26fb516cfa85ea8de81dd623a893ffd579ee2a00deb6cdf3633d6bb', 166 | b'0382910eae483ce4213d79d107bfc78f3d77e2a31ea597be45256171ad0abeaa89' 167 | ]), 168 | b'00483045022100fec82ed82687874f2a29cbdc8334e114af645c45298e85bb1efe69fcf15c617a0220575e' 169 | b'40399f9ada388d8e522899f4ec3b7256896dd9b02742f6567d960b613f0401473044022024890462f731bd' 170 | b'1a42a4716797bad94761fc4112e359117e591c07b8520ea33b02201ac689e35c4648e6beff1d42490207ba' 171 | b'14027a638a62663b2ee40153299141eb014830450221009910823e0142967a73c2d16c1560054d71c0625a' 172 | b'385904ba2f1f53e0bc1daa8d02205cd70a89c6cf031a8b07d1d5eb0d65d108c4d49c2d403f84fb03ad3dc3' 173 | b'18777a014cad53210372ba1fd35e5f1b1437cba0c4ebfc4025b7349366f9f9c7c8c4b03a47bd3f68a42103' 174 | b'061d250182b2db1ba144167fd8b0ef3fe0fc3a2fa046958f835ffaf0dfdb76922102463bfbc1eaec74b5c2' 175 | b'1c09239ae18dbf6fc07833917df10d0b43e322810cee0c2102fa6a6455c26fb516cfa85ea8de81dd623a89' 176 | b'3ffd579ee2a00deb6cdf3633d6bb210382910eae483ce4213d79d107bfc78f3d77e2a31ea597be45256171' 177 | b'ad0abeaa8955ae' 178 | ) 179 | 180 | 181 | class TestPayPubKeyHash(unittest.TestCase): 182 | 183 | def pay_pubkey_hash(self, pubkey_hash): 184 | # this checks that factory function correctly sets up the script 185 | src1 = BaseOutputScript.pay_pubkey_hash(unhexlify(pubkey_hash)) 186 | self.assertEqual(src1.template.name, 'pay_pubkey_hash') 187 | self.assertEqual(hexlify(src1.values['pubkey_hash']), pubkey_hash) 188 | # now we test that it will round trip 189 | src2 = BaseOutputScript(src1.source) 190 | self.assertEqual(src2.template.name, 'pay_pubkey_hash') 191 | self.assertEqual(hexlify(src2.values['pubkey_hash']), pubkey_hash) 192 | return hexlify(src1.source) 193 | 194 | def test_pay_pubkey_hash_1(self): 195 | self.assertEqual( 196 | self.pay_pubkey_hash(b'64d74d12acc93ba1ad495e8d2d0523252d664f4d'), 197 | b'76a91464d74d12acc93ba1ad495e8d2d0523252d664f4d88ac' 198 | ) 199 | 200 | 201 | class TestPayScriptHash(unittest.TestCase): 202 | 203 | def pay_script_hash(self, script_hash): 204 | # this checks that factory function correctly sets up the script 205 | src1 = BaseOutputScript.pay_script_hash(unhexlify(script_hash)) 206 | self.assertEqual(src1.template.name, 'pay_script_hash') 207 | self.assertEqual(hexlify(src1.values['script_hash']), script_hash) 208 | # now we test that it will round trip 209 | src2 = BaseOutputScript(src1.source) 210 | self.assertEqual(src2.template.name, 'pay_script_hash') 211 | self.assertEqual(hexlify(src2.values['script_hash']), script_hash) 212 | return hexlify(src1.source) 213 | 214 | def test_pay_pubkey_hash_1(self): 215 | self.assertEqual( 216 | self.pay_script_hash(b'63d65a2ee8c44426d06050cfd71c0f0ff3fc41ac'), 217 | b'a91463d65a2ee8c44426d06050cfd71c0f0ff3fc41ac87' 218 | ) 219 | -------------------------------------------------------------------------------- /tests/client_tests/unit/test_utils.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | 3 | from torba.client.util import ArithUint256 4 | from torba.client.util import coins_to_satoshis as c2s, satoshis_to_coins as s2c 5 | 6 | 7 | class TestCoinValueParsing(unittest.TestCase): 8 | 9 | def test_good_output(self): 10 | self.assertEqual(s2c(1), "0.00000001") 11 | self.assertEqual(s2c(10**7), "0.1") 12 | self.assertEqual(s2c(2*10**8), "2.0") 13 | self.assertEqual(s2c(2*10**17), "2000000000.0") 14 | 15 | def test_good_input(self): 16 | self.assertEqual(c2s("0.00000001"), 1) 17 | self.assertEqual(c2s("0.1"), 10**7) 18 | self.assertEqual(c2s("1.0"), 10**8) 19 | self.assertEqual(c2s("2.00000000"), 2*10**8) 20 | self.assertEqual(c2s("2000000000.0"), 2*10**17) 21 | 22 | def test_bad_input(self): 23 | with self.assertRaises(ValueError): 24 | c2s("1") 25 | with self.assertRaises(ValueError): 26 | c2s("-1.0") 27 | with self.assertRaises(ValueError): 28 | c2s("10000000000.0") 29 | with self.assertRaises(ValueError): 30 | c2s("1.000000000") 31 | with self.assertRaises(ValueError): 32 | c2s("-0") 33 | with self.assertRaises(ValueError): 34 | c2s("1") 35 | with self.assertRaises(ValueError): 36 | c2s(".1") 37 | with self.assertRaises(ValueError): 38 | c2s("1e-7") 39 | 40 | 41 | class TestArithUint256(unittest.TestCase): 42 | 43 | def test_arithunit256(self): 44 | # https://github.com/bitcoin/bitcoin/blob/master/src/test/arith_uint256_tests.cpp 45 | 46 | from_compact = ArithUint256.from_compact 47 | eq = self.assertEqual 48 | 49 | eq(from_compact(0).value, 0) 50 | eq(from_compact(0x00123456).value, 0) 51 | eq(from_compact(0x01003456).value, 0) 52 | eq(from_compact(0x02000056).value, 0) 53 | eq(from_compact(0x03000000).value, 0) 54 | eq(from_compact(0x04000000).value, 0) 55 | eq(from_compact(0x00923456).value, 0) 56 | eq(from_compact(0x01803456).value, 0) 57 | eq(from_compact(0x02800056).value, 0) 58 | eq(from_compact(0x03800000).value, 0) 59 | eq(from_compact(0x04800000).value, 0) 60 | 61 | # Make sure that we don't generate compacts with the 0x00800000 bit set 62 | uint = ArithUint256(0x80) 63 | eq(uint.compact, 0x02008000) 64 | 65 | uint = from_compact(0x01123456) 66 | eq(uint.value, 0x12) 67 | eq(uint.compact, 0x01120000) 68 | 69 | uint = from_compact(0x01fedcba) 70 | eq(uint.value, 0x7e) 71 | eq(uint.negative, 0x01fe0000) 72 | 73 | uint = from_compact(0x02123456) 74 | eq(uint.value, 0x1234) 75 | eq(uint.compact, 0x02123400) 76 | 77 | uint = from_compact(0x03123456) 78 | eq(uint.value, 0x123456) 79 | eq(uint.compact, 0x03123456) 80 | 81 | uint = from_compact(0x04123456) 82 | eq(uint.value, 0x12345600) 83 | eq(uint.compact, 0x04123456) 84 | 85 | uint = from_compact(0x04923456) 86 | eq(uint.value, 0x12345600) 87 | eq(uint.negative, 0x04923456) 88 | 89 | uint = from_compact(0x05009234) 90 | eq(uint.value, 0x92340000) 91 | eq(uint.compact, 0x05009234) 92 | 93 | uint = from_compact(0x20123456) 94 | eq(uint.value, 0x1234560000000000000000000000000000000000000000000000000000000000) 95 | eq(uint.compact, 0x20123456) 96 | -------------------------------------------------------------------------------- /tests/client_tests/unit/test_wallet.py: -------------------------------------------------------------------------------- 1 | import tempfile 2 | from binascii import hexlify 3 | 4 | from torba.testcase import AsyncioTestCase 5 | 6 | from torba.coin.bitcoinsegwit import MainNetLedger as BTCLedger 7 | from torba.coin.bitcoincash import MainNetLedger as BCHLedger 8 | from torba.client.basemanager import BaseWalletManager 9 | from torba.client.wallet import Wallet, WalletStorage 10 | 11 | 12 | class TestWalletCreation(AsyncioTestCase): 13 | 14 | async def asyncSetUp(self): 15 | self.manager = BaseWalletManager() 16 | config = {'data_path': '/tmp/wallet'} 17 | self.btc_ledger = self.manager.get_or_create_ledger(BTCLedger.get_id(), config) 18 | self.bch_ledger = self.manager.get_or_create_ledger(BCHLedger.get_id(), config) 19 | 20 | def test_create_wallet_and_accounts(self): 21 | wallet = Wallet() 22 | self.assertEqual(wallet.name, 'Wallet') 23 | self.assertEqual(wallet.accounts, []) 24 | 25 | account1 = wallet.generate_account(self.btc_ledger) 26 | wallet.generate_account(self.btc_ledger) 27 | wallet.generate_account(self.bch_ledger) 28 | self.assertEqual(wallet.default_account, account1) 29 | self.assertEqual(len(wallet.accounts), 3) 30 | 31 | def test_load_and_save_wallet(self): 32 | wallet_dict = { 33 | 'version': 1, 34 | 'name': 'Main Wallet', 35 | 'accounts': [ 36 | { 37 | 'name': 'An Account', 38 | 'ledger': 'btc_mainnet', 39 | 'modified_on': 123.456, 40 | 'seed': 41 | "carbon smart garage balance margin twelve chest sword toast envelope bottom stomac" 42 | "h absent", 43 | 'encrypted': False, 44 | 'private_key': 45 | 'xprv9s21ZrQH143K3TsAz5efNV8K93g3Ms3FXcjaWB9fVUsMwAoE3Z' 46 | 'T4vYymkp5BxKKfnpz8J6sHDFriX1SnpvjNkzcks8XBnxjGLS83BTyfpna', 47 | 'public_key': 48 | 'xpub661MyMwAqRbcFwwe67Bfjd53h5WXmKm6tqfBJZZH3pQLoy8Nb6' 49 | 'mKUMJFc7UbpVNzmwFPN2evn3YHnig1pkKVYcvCV8owTd2yAcEkJfCX53g', 50 | 'address_generator': { 51 | 'name': 'deterministic-chain', 52 | 'receiving': {'gap': 17, 'maximum_uses_per_address': 3}, 53 | 'change': {'gap': 10, 'maximum_uses_per_address': 3} 54 | } 55 | } 56 | ] 57 | } 58 | 59 | storage = WalletStorage(default=wallet_dict) 60 | wallet = Wallet.from_storage(storage, self.manager) 61 | self.assertEqual(wallet.name, 'Main Wallet') 62 | self.assertEqual( 63 | hexlify(wallet.hash), b'9f462b8dd802eb8c913e54f09a09827ebc14abbc13f33baa90d8aec5ae920fc7' 64 | ) 65 | self.assertEqual(len(wallet.accounts), 1) 66 | account = wallet.default_account 67 | self.assertIsInstance(account, BTCLedger.account_class) 68 | self.maxDiff = None 69 | self.assertDictEqual(wallet_dict, wallet.to_dict()) 70 | 71 | encrypted = wallet.pack('password') 72 | decrypted = Wallet.unpack('password', encrypted) 73 | self.assertEqual(decrypted['accounts'][0]['name'], 'An Account') 74 | 75 | def test_read_write(self): 76 | manager = BaseWalletManager() 77 | config = {'data_path': '/tmp/wallet'} 78 | ledger = manager.get_or_create_ledger(BTCLedger.get_id(), config) 79 | 80 | with tempfile.NamedTemporaryFile(suffix='.json') as wallet_file: 81 | wallet_file.write(b'{"version": 1}') 82 | wallet_file.seek(0) 83 | 84 | # create and write wallet to a file 85 | wallet = manager.import_wallet(wallet_file.name) 86 | account = wallet.generate_account(ledger) 87 | wallet.save() 88 | 89 | # read wallet from file 90 | wallet_storage = WalletStorage(wallet_file.name) 91 | wallet = Wallet.from_storage(wallet_storage, manager) 92 | 93 | self.assertEqual(account.public_key.address, wallet.default_account.public_key.address) 94 | -------------------------------------------------------------------------------- /torba.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lbryio/torba/190304344c0ff68f8a24cf50272307a11bf7f62b/torba.png -------------------------------------------------------------------------------- /torba/__init__.py: -------------------------------------------------------------------------------- 1 | __path__: str = __import__('pkgutil').extend_path(__path__, __name__) 2 | __version__ = '0.5.7' 3 | -------------------------------------------------------------------------------- /torba/client/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lbryio/torba/190304344c0ff68f8a24cf50272307a11bf7f62b/torba/client/__init__.py -------------------------------------------------------------------------------- /torba/client/baseheader.py: -------------------------------------------------------------------------------- 1 | import os 2 | import asyncio 3 | import logging 4 | from io import BytesIO 5 | from typing import Optional, Iterator, Tuple 6 | from binascii import hexlify 7 | 8 | from torba.client.util import ArithUint256 9 | from torba.client.hash import double_sha256 10 | 11 | log = logging.getLogger(__name__) 12 | 13 | 14 | class InvalidHeader(Exception): 15 | 16 | def __init__(self, height, message): 17 | super().__init__(message) 18 | self.message = message 19 | self.height = height 20 | 21 | 22 | class BaseHeaders: 23 | 24 | header_size: int 25 | chunk_size: int 26 | 27 | max_target: int 28 | genesis_hash: Optional[bytes] 29 | target_timespan: int 30 | 31 | validate_difficulty: bool = True 32 | 33 | def __init__(self, path) -> None: 34 | if path == ':memory:': 35 | self.io = BytesIO() 36 | self.path = path 37 | self._size: Optional[int] = None 38 | self._header_connect_lock = asyncio.Lock() 39 | 40 | async def open(self): 41 | if self.path != ':memory:': 42 | if not os.path.exists(self.path): 43 | self.io = open(self.path, 'w+b') 44 | else: 45 | self.io = open(self.path, 'r+b') 46 | 47 | async def close(self): 48 | self.io.close() 49 | 50 | @staticmethod 51 | def serialize(header: dict) -> bytes: 52 | raise NotImplementedError 53 | 54 | @staticmethod 55 | def deserialize(height, header): 56 | raise NotImplementedError 57 | 58 | def get_next_chunk_target(self, chunk: int) -> ArithUint256: 59 | return ArithUint256(self.max_target) 60 | 61 | @staticmethod 62 | def get_next_block_target(chunk_target: ArithUint256, previous: Optional[dict], 63 | current: Optional[dict]) -> ArithUint256: 64 | return chunk_target 65 | 66 | def __len__(self) -> int: 67 | if self._size is None: 68 | self._size = self.io.seek(0, os.SEEK_END) // self.header_size 69 | return self._size 70 | 71 | def __bool__(self): 72 | return True 73 | 74 | def __getitem__(self, height) -> dict: 75 | assert not isinstance(height, slice), \ 76 | "Slicing of header chain has not been implemented yet." 77 | return self.deserialize(height, self.get_raw_header(height)) 78 | 79 | def get_raw_header(self, height) -> bytes: 80 | self.io.seek(height * self.header_size, os.SEEK_SET) 81 | return self.io.read(self.header_size) 82 | 83 | @property 84 | def height(self) -> int: 85 | return len(self)-1 86 | 87 | def hash(self, height=None) -> bytes: 88 | return self.hash_header( 89 | self.get_raw_header(height if height is not None else self.height) 90 | ) 91 | 92 | @staticmethod 93 | def hash_header(header: bytes) -> bytes: 94 | if header is None: 95 | return b'0' * 64 96 | return hexlify(double_sha256(header)[::-1]) 97 | 98 | async def connect(self, start: int, headers: bytes) -> int: 99 | added = 0 100 | bail = False 101 | loop = asyncio.get_running_loop() 102 | async with self._header_connect_lock: 103 | for height, chunk in self._iterate_chunks(start, headers): 104 | try: 105 | # validate_chunk() is CPU bound and reads previous chunks from file system 106 | await loop.run_in_executor(None, self.validate_chunk, height, chunk) 107 | except InvalidHeader as e: 108 | bail = True 109 | chunk = chunk[:(height-e.height)*self.header_size] 110 | written = 0 111 | if chunk: 112 | self.io.seek(height * self.header_size, os.SEEK_SET) 113 | written = self.io.write(chunk) // self.header_size 114 | self.io.truncate() 115 | # .seek()/.write()/.truncate() might also .flush() when needed 116 | # the goal here is mainly to ensure we're definitely flush()'ing 117 | await loop.run_in_executor(None, self.io.flush) 118 | self._size = None 119 | added += written 120 | if bail: 121 | break 122 | return added 123 | 124 | def validate_chunk(self, height, chunk): 125 | previous_hash, previous_header, previous_previous_header = None, None, None 126 | if height > 0: 127 | previous_header = self[height-1] 128 | previous_hash = self.hash(height-1) 129 | if height > 1: 130 | previous_previous_header = self[height-2] 131 | chunk_target = self.get_next_chunk_target(height // 2016 - 1) 132 | for current_hash, current_header in self._iterate_headers(height, chunk): 133 | block_target = self.get_next_block_target(chunk_target, previous_previous_header, previous_header) 134 | self.validate_header(height, current_hash, current_header, previous_hash, block_target) 135 | previous_previous_header = previous_header 136 | previous_header = current_header 137 | previous_hash = current_hash 138 | 139 | def validate_header(self, height: int, current_hash: bytes, 140 | header: dict, previous_hash: bytes, target: ArithUint256): 141 | 142 | if previous_hash is None: 143 | if self.genesis_hash is not None and self.genesis_hash != current_hash: 144 | raise InvalidHeader( 145 | height, "genesis header doesn't match: {} vs expected {}".format( 146 | current_hash.decode(), self.genesis_hash.decode()) 147 | ) 148 | return 149 | 150 | if header['prev_block_hash'] != previous_hash: 151 | raise InvalidHeader( 152 | height, "previous hash mismatch: {} vs expected {}".format( 153 | header['prev_block_hash'].decode(), previous_hash.decode()) 154 | ) 155 | 156 | if self.validate_difficulty: 157 | 158 | if header['bits'] != target.compact: 159 | raise InvalidHeader( 160 | height, "bits mismatch: {} vs expected {}".format( 161 | header['bits'], target.compact) 162 | ) 163 | 164 | proof_of_work = self.get_proof_of_work(current_hash) 165 | if proof_of_work > target: 166 | raise InvalidHeader( 167 | height, "insufficient proof of work: {} vs target {}".format( 168 | proof_of_work.value, target.value) 169 | ) 170 | 171 | @staticmethod 172 | def get_proof_of_work(header_hash: bytes) -> ArithUint256: 173 | return ArithUint256(int(b'0x' + header_hash, 16)) 174 | 175 | def _iterate_chunks(self, height: int, headers: bytes) -> Iterator[Tuple[int, bytes]]: 176 | assert len(headers) % self.header_size == 0 177 | start = 0 178 | end = (self.chunk_size - height % self.chunk_size) * self.header_size 179 | while start < end: 180 | yield height + (start // self.header_size), headers[start:end] 181 | start = end 182 | end = min(len(headers), end + self.chunk_size * self.header_size) 183 | 184 | def _iterate_headers(self, height: int, headers: bytes) -> Iterator[Tuple[bytes, dict]]: 185 | assert len(headers) % self.header_size == 0 186 | for idx in range(len(headers) // self.header_size): 187 | start, end = idx * self.header_size, (idx + 1) * self.header_size 188 | header = headers[start:end] 189 | yield self.hash_header(header), self.deserialize(height+idx, header) 190 | -------------------------------------------------------------------------------- /torba/client/basemanager.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | from typing import Type, MutableSequence, MutableMapping 4 | 5 | from torba.client.baseledger import BaseLedger, LedgerRegistry 6 | from torba.client.wallet import Wallet, WalletStorage 7 | 8 | log = logging.getLogger(__name__) 9 | 10 | 11 | class BaseWalletManager: 12 | 13 | def __init__(self, wallets: MutableSequence[Wallet] = None, 14 | ledgers: MutableMapping[Type[BaseLedger], BaseLedger] = None) -> None: 15 | self.wallets = wallets or [] 16 | self.ledgers = ledgers or {} 17 | self.running = False 18 | 19 | @classmethod 20 | def from_config(cls, config: dict) -> 'BaseWalletManager': 21 | manager = cls() 22 | for ledger_id, ledger_config in config.get('ledgers', {}).items(): 23 | manager.get_or_create_ledger(ledger_id, ledger_config) 24 | for wallet_path in config.get('wallets', []): 25 | wallet_storage = WalletStorage(wallet_path) 26 | wallet = Wallet.from_storage(wallet_storage, manager) 27 | manager.wallets.append(wallet) 28 | return manager 29 | 30 | def get_or_create_ledger(self, ledger_id, ledger_config=None): 31 | ledger_class = LedgerRegistry.get_ledger_class(ledger_id) 32 | ledger = self.ledgers.get(ledger_class) 33 | if ledger is None: 34 | ledger = ledger_class(ledger_config or {}) 35 | self.ledgers[ledger_class] = ledger 36 | return ledger 37 | 38 | def import_wallet(self, path): 39 | storage = WalletStorage(path) 40 | wallet = Wallet.from_storage(storage, self) 41 | self.wallets.append(wallet) 42 | return wallet 43 | 44 | async def get_detailed_accounts(self, **kwargs): 45 | ledgers = {} 46 | for i, account in enumerate(self.accounts): 47 | details = await account.get_details(**kwargs) 48 | details['is_default'] = i == 0 49 | ledger_id = account.ledger.get_id() 50 | ledgers.setdefault(ledger_id, []) 51 | ledgers[ledger_id].append(details) 52 | return ledgers 53 | 54 | @property 55 | def default_wallet(self): 56 | for wallet in self.wallets: 57 | return wallet 58 | 59 | @property 60 | def default_account(self): 61 | for wallet in self.wallets: 62 | return wallet.default_account 63 | 64 | @property 65 | def accounts(self): 66 | for wallet in self.wallets: 67 | for account in wallet.accounts: 68 | yield account 69 | 70 | async def start(self): 71 | self.running = True 72 | await asyncio.gather(*( 73 | l.start() for l in self.ledgers.values() 74 | )) 75 | 76 | async def stop(self): 77 | await asyncio.gather(*( 78 | l.stop() for l in self.ledgers.values() 79 | )) 80 | self.running = False 81 | -------------------------------------------------------------------------------- /torba/client/basenetwork.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import asyncio 3 | from asyncio import CancelledError 4 | from time import time 5 | from typing import List 6 | 7 | from torba.rpc import RPCSession as BaseClientSession, Connector, RPCError 8 | 9 | from torba import __version__ 10 | from torba.stream import StreamController 11 | 12 | log = logging.getLogger(__name__) 13 | 14 | 15 | class ClientSession(BaseClientSession): 16 | 17 | def __init__(self, *args, network, server, **kwargs): 18 | self.network = network 19 | self.server = server 20 | super().__init__(*args, **kwargs) 21 | self._on_disconnect_controller = StreamController() 22 | self.on_disconnected = self._on_disconnect_controller.stream 23 | self.bw_limit = self.framer.max_size = self.max_errors = 1 << 32 24 | self.max_seconds_idle = 60 25 | self.ping_task = None 26 | 27 | async def send_request(self, method, args=()): 28 | try: 29 | return await super().send_request(method, args) 30 | except RPCError as e: 31 | log.warning("Wallet server returned an error. Code: %s Message: %s", *e.args) 32 | raise e 33 | 34 | async def ping_forever(self): 35 | # TODO: change to 'ping' on newer protocol (above 1.2) 36 | while not self.is_closing(): 37 | if (time() - self.last_send) > self.max_seconds_idle: 38 | await self.send_request('server.banner') 39 | await asyncio.sleep(self.max_seconds_idle//3) 40 | 41 | async def create_connection(self, timeout=6): 42 | connector = Connector(lambda: self, *self.server) 43 | await asyncio.wait_for(connector.create_connection(), timeout=timeout) 44 | self.ping_task = asyncio.create_task(self.ping_forever()) 45 | 46 | async def handle_request(self, request): 47 | controller = self.network.subscription_controllers[request.method] 48 | controller.add(request.args) 49 | 50 | def connection_lost(self, exc): 51 | super().connection_lost(exc) 52 | self._on_disconnect_controller.add(True) 53 | if self.ping_task: 54 | self.ping_task.cancel() 55 | 56 | 57 | class BaseNetwork: 58 | 59 | def __init__(self, ledger): 60 | self.config = ledger.config 61 | self.client: ClientSession = None 62 | self.session_pool: SessionPool = None 63 | self.running = False 64 | 65 | self._on_connected_controller = StreamController() 66 | self.on_connected = self._on_connected_controller.stream 67 | 68 | self._on_header_controller = StreamController() 69 | self.on_header = self._on_header_controller.stream 70 | 71 | self._on_status_controller = StreamController() 72 | self.on_status = self._on_status_controller.stream 73 | 74 | self.subscription_controllers = { 75 | 'blockchain.headers.subscribe': self._on_header_controller, 76 | 'blockchain.address.subscribe': self._on_status_controller, 77 | } 78 | 79 | async def start(self): 80 | self.running = True 81 | connect_timeout = self.config.get('connect_timeout', 6) 82 | self.session_pool = SessionPool(network=self, timeout=connect_timeout) 83 | self.session_pool.start(self.config['default_servers']) 84 | while True: 85 | try: 86 | self.client = await self.pick_fastest_session() 87 | if self.is_connected: 88 | await self.ensure_server_version() 89 | log.info("Successfully connected to SPV wallet server: %s:%d", *self.client.server) 90 | self._on_connected_controller.add(True) 91 | await self.client.on_disconnected.first 92 | except CancelledError: 93 | self.running = False 94 | except asyncio.TimeoutError: 95 | log.warning("Timed out while trying to find a server!") 96 | except Exception: # pylint: disable=broad-except 97 | log.exception("Exception while trying to find a server!") 98 | if not self.running: 99 | return 100 | elif self.client: 101 | await self.client.close() 102 | self.client.connection.cancel_pending_requests() 103 | 104 | async def stop(self): 105 | self.running = False 106 | if self.session_pool: 107 | self.session_pool.stop() 108 | if self.is_connected: 109 | disconnected = self.client.on_disconnected.first 110 | await self.client.close() 111 | await disconnected 112 | 113 | @property 114 | def is_connected(self): 115 | return self.client is not None and not self.client.is_closing() 116 | 117 | def rpc(self, list_or_method, args): 118 | if self.is_connected: 119 | return self.client.send_request(list_or_method, args) 120 | else: 121 | raise ConnectionError("Attempting to send rpc request when connection is not available.") 122 | 123 | async def pick_fastest_session(self): 124 | sessions = await self.session_pool.get_online_sessions() 125 | done, pending = await asyncio.wait([ 126 | self.probe_session(session) 127 | for session in sessions if not session.is_closing() 128 | ], return_when='FIRST_COMPLETED') 129 | for task in pending: 130 | task.cancel() 131 | for session in done: 132 | return await session 133 | 134 | async def probe_session(self, session: ClientSession): 135 | await session.send_request('server.banner') 136 | return session 137 | 138 | def ensure_server_version(self, required='1.2'): 139 | return self.rpc('server.version', [__version__, required]) 140 | 141 | def broadcast(self, raw_transaction): 142 | return self.rpc('blockchain.transaction.broadcast', [raw_transaction]) 143 | 144 | def get_history(self, address): 145 | return self.rpc('blockchain.address.get_history', [address]) 146 | 147 | def get_transaction(self, tx_hash): 148 | return self.rpc('blockchain.transaction.get', [tx_hash]) 149 | 150 | def get_transaction_height(self, tx_hash): 151 | return self.rpc('blockchain.transaction.get_height', [tx_hash]) 152 | 153 | def get_merkle(self, tx_hash, height): 154 | return self.rpc('blockchain.transaction.get_merkle', [tx_hash, height]) 155 | 156 | def get_headers(self, height, count=10000): 157 | return self.rpc('blockchain.block.headers', [height, count]) 158 | 159 | def subscribe_headers(self): 160 | return self.rpc('blockchain.headers.subscribe', [True]) 161 | 162 | def subscribe_address(self, address): 163 | return self.rpc('blockchain.address.subscribe', [address]) 164 | 165 | 166 | class SessionPool: 167 | 168 | def __init__(self, network: BaseNetwork, timeout: float): 169 | self.network = network 170 | self.sessions: List[ClientSession] = [] 171 | self._dead_servers: List[ClientSession] = [] 172 | self.maintain_connections_task = None 173 | self.timeout = timeout 174 | # triggered when the master server is out, to speed up reconnect 175 | self._lost_master = asyncio.Event() 176 | 177 | @property 178 | def online(self): 179 | for session in self.sessions: 180 | if not session.is_closing(): 181 | return True 182 | return False 183 | 184 | def start(self, default_servers): 185 | self.sessions = [ 186 | ClientSession(network=self.network, server=server) 187 | for server in default_servers 188 | ] 189 | self.maintain_connections_task = asyncio.create_task(self.ensure_connections()) 190 | 191 | def stop(self): 192 | if self.maintain_connections_task: 193 | self.maintain_connections_task.cancel() 194 | for session in self.sessions: 195 | if not session.is_closing(): 196 | session.abort() 197 | self.sessions, self._dead_servers, self.maintain_connections_task = [], [], None 198 | 199 | async def ensure_connections(self): 200 | while True: 201 | await asyncio.gather(*[ 202 | self.ensure_connection(session) 203 | for session in self.sessions 204 | ], return_exceptions=True) 205 | await asyncio.wait([asyncio.sleep(3), self._lost_master.wait()], return_when='FIRST_COMPLETED') 206 | self._lost_master.clear() 207 | if not self.sessions: 208 | self.sessions.extend(self._dead_servers) 209 | self._dead_servers = [] 210 | 211 | async def ensure_connection(self, session): 212 | if not session.is_closing(): 213 | return 214 | try: 215 | return await session.create_connection(self.timeout) 216 | except asyncio.TimeoutError: 217 | log.warning("Timeout connecting to %s:%d", *session.server) 218 | except asyncio.CancelledError: # pylint: disable=try-except-raise 219 | raise 220 | except Exception as err: # pylint: disable=broad-except 221 | if 'Connect call failed' in str(err): 222 | log.warning("Could not connect to %s:%d", *session.server) 223 | else: 224 | log.exception("Connecting to %s:%d raised an exception:", *session.server) 225 | self._dead_servers.append(session) 226 | self.sessions.remove(session) 227 | 228 | async def get_online_sessions(self): 229 | self._lost_master.set() 230 | while not self.online: 231 | await asyncio.sleep(0.1) 232 | return self.sessions 233 | -------------------------------------------------------------------------------- /torba/client/bcd_data_stream.py: -------------------------------------------------------------------------------- 1 | import struct 2 | from io import BytesIO 3 | 4 | 5 | class BCDataStream: 6 | 7 | def __init__(self, data=None): 8 | self.data = BytesIO(data) 9 | 10 | def reset(self): 11 | self.data.seek(0) 12 | 13 | def get_bytes(self): 14 | return self.data.getvalue() 15 | 16 | def read(self, size): 17 | return self.data.read(size) 18 | 19 | def write(self, data): 20 | self.data.write(data) 21 | 22 | def write_many(self, many): 23 | self.data.writelines(many) 24 | 25 | def read_string(self): 26 | return self.read(self.read_compact_size()) 27 | 28 | def write_string(self, s): 29 | self.write_compact_size(len(s)) 30 | self.write(s) 31 | 32 | def read_compact_size(self): 33 | size = self.read_uint8() 34 | if size < 253: 35 | return size 36 | if size == 253: 37 | return self.read_uint16() 38 | if size == 254: 39 | return self.read_uint32() 40 | if size == 255: 41 | return self.read_uint64() 42 | 43 | def write_compact_size(self, size): 44 | if size < 253: 45 | self.write_uint8(size) 46 | elif size <= 0xFFFF: 47 | self.write_uint8(253) 48 | self.write_uint16(size) 49 | elif size <= 0xFFFFFFFF: 50 | self.write_uint8(254) 51 | self.write_uint32(size) 52 | else: 53 | self.write_uint8(255) 54 | self.write_uint64(size) 55 | 56 | def read_boolean(self): 57 | return self.read_uint8() != 0 58 | 59 | def write_boolean(self, val): 60 | return self.write_uint8(1 if val else 0) 61 | 62 | int8 = struct.Struct('b') 63 | uint8 = struct.Struct('B') 64 | int16 = struct.Struct('<h') 65 | uint16 = struct.Struct('<H') 66 | int32 = struct.Struct('<i') 67 | uint32 = struct.Struct('<I') 68 | int64 = struct.Struct('<q') 69 | uint64 = struct.Struct('<Q') 70 | 71 | def _read_struct(self, fmt): 72 | value = self.read(fmt.size) 73 | if value: 74 | return fmt.unpack(value)[0] 75 | 76 | def read_int8(self): 77 | return self._read_struct(self.int8) 78 | 79 | def read_uint8(self): 80 | return self._read_struct(self.uint8) 81 | 82 | def read_int16(self): 83 | return self._read_struct(self.int16) 84 | 85 | def read_uint16(self): 86 | return self._read_struct(self.uint16) 87 | 88 | def read_int32(self): 89 | return self._read_struct(self.int32) 90 | 91 | def read_uint32(self): 92 | return self._read_struct(self.uint32) 93 | 94 | def read_int64(self): 95 | return self._read_struct(self.int64) 96 | 97 | def read_uint64(self): 98 | return self._read_struct(self.uint64) 99 | 100 | def write_int8(self, val): 101 | self.write(self.int8.pack(val)) 102 | 103 | def write_uint8(self, val): 104 | self.write(self.uint8.pack(val)) 105 | 106 | def write_int16(self, val): 107 | self.write(self.int16.pack(val)) 108 | 109 | def write_uint16(self, val): 110 | self.write(self.uint16.pack(val)) 111 | 112 | def write_int32(self, val): 113 | self.write(self.int32.pack(val)) 114 | 115 | def write_uint32(self, val): 116 | self.write(self.uint32.pack(val)) 117 | 118 | def write_int64(self, val): 119 | self.write(self.int64.pack(val)) 120 | 121 | def write_uint64(self, val): 122 | self.write(self.uint64.pack(val)) 123 | -------------------------------------------------------------------------------- /torba/client/cli.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import argparse 3 | import asyncio 4 | import aiohttp 5 | 6 | from torba.orchstr8.node import Conductor, get_ledger_from_environment, get_blockchain_node_from_ledger 7 | from torba.orchstr8.service import ConductorService 8 | 9 | 10 | def get_argument_parser(): 11 | parser = argparse.ArgumentParser( 12 | prog="torba" 13 | ) 14 | subparsers = parser.add_subparsers(dest='command', help='sub-command help') 15 | 16 | subparsers.add_parser("gui", help="Start Qt GUI.") 17 | 18 | subparsers.add_parser("download", help="Download blockchain node binary.") 19 | 20 | start = subparsers.add_parser("start", help="Start orchstr8 service.") 21 | start.add_argument("--blockchain", help="Start blockchain node.", action="store_true") 22 | start.add_argument("--spv", help="Start SPV server.", action="store_true") 23 | start.add_argument("--wallet", help="Start wallet daemon.", action="store_true") 24 | 25 | generate = subparsers.add_parser("generate", help="Call generate method on running orchstr8 instance.") 26 | generate.add_argument("blocks", type=int, help="Number of blocks to generate") 27 | 28 | subparsers.add_parser("transfer", help="Call transfer method on running orchstr8 instance.") 29 | return parser 30 | 31 | 32 | async def run_remote_command(command, **kwargs): 33 | async with aiohttp.ClientSession() as session: 34 | async with session.post('http://localhost:7954/'+command, data=kwargs) as resp: 35 | print(resp.status) 36 | print(await resp.text()) 37 | 38 | 39 | def main(): 40 | parser = get_argument_parser() 41 | args = parser.parse_args() 42 | command = getattr(args, 'command', 'help') 43 | 44 | if command == 'gui': 45 | from torba.workbench import main as start_app # pylint: disable=E0611,E0401 46 | return start_app() 47 | 48 | loop = asyncio.get_event_loop() 49 | ledger = get_ledger_from_environment() 50 | 51 | if command == 'download': 52 | logging.getLogger('blockchain').setLevel(logging.INFO) 53 | get_blockchain_node_from_ledger(ledger).ensure() 54 | 55 | elif command == 'generate': 56 | loop.run_until_complete(run_remote_command( 57 | 'generate', blocks=args.blocks 58 | )) 59 | 60 | elif command == 'start': 61 | 62 | conductor = Conductor() 63 | if getattr(args, 'blockchain', False): 64 | loop.run_until_complete(conductor.start_blockchain()) 65 | if getattr(args, 'spv', False): 66 | loop.run_until_complete(conductor.start_spv()) 67 | if getattr(args, 'wallet', False): 68 | loop.run_until_complete(conductor.start_wallet()) 69 | 70 | service = ConductorService(conductor, loop) 71 | loop.run_until_complete(service.start()) 72 | 73 | try: 74 | print('========== Orchstr8 API Service Started ========') 75 | loop.run_forever() 76 | except KeyboardInterrupt: 77 | pass 78 | finally: 79 | loop.run_until_complete(service.stop()) 80 | loop.run_until_complete(conductor.stop()) 81 | 82 | loop.close() 83 | 84 | else: 85 | parser.print_help() 86 | 87 | 88 | if __name__ == "__main__": 89 | main() 90 | -------------------------------------------------------------------------------- /torba/client/coinselection.py: -------------------------------------------------------------------------------- 1 | from random import Random 2 | from typing import List 3 | 4 | from torba.client import basetransaction 5 | 6 | MAXIMUM_TRIES = 100000 7 | 8 | STRATEGIES = [] 9 | 10 | def strategy(method): 11 | STRATEGIES.append(method.__name__) 12 | return method 13 | 14 | 15 | class CoinSelector: 16 | 17 | def __init__(self, txos: List[basetransaction.BaseOutputEffectiveAmountEstimator], 18 | target: int, cost_of_change: int, seed: str = None) -> None: 19 | self.txos = txos 20 | self.target = target 21 | self.cost_of_change = cost_of_change 22 | self.exact_match = False 23 | self.tries = 0 24 | self.available = sum(c.effective_amount for c in self.txos) 25 | self.random = Random(seed) 26 | if seed is not None: 27 | self.random.seed(seed, version=1) 28 | 29 | def select(self, strategy_name: str = None) -> List[basetransaction.BaseOutputEffectiveAmountEstimator]: 30 | if not self.txos: 31 | return [] 32 | if self.target > self.available: 33 | return [] 34 | if strategy_name is not None: 35 | return getattr(self, strategy_name)() 36 | return ( 37 | self.branch_and_bound() or 38 | self.closest_match() or 39 | self.random_draw() 40 | ) 41 | 42 | @strategy 43 | def prefer_confirmed(self) -> List[basetransaction.BaseOutputEffectiveAmountEstimator]: 44 | self.txos = [t for t in self.txos if t.txo.tx_ref and t.txo.tx_ref.height > 0] or self.txos 45 | self.available = sum(c.effective_amount for c in self.txos) 46 | return ( 47 | self.branch_and_bound() or 48 | self.closest_match() or 49 | self.random_draw() 50 | ) 51 | 52 | @strategy 53 | def branch_and_bound(self) -> List[basetransaction.BaseOutputEffectiveAmountEstimator]: 54 | # see bitcoin implementation for more info: 55 | # https://github.com/bitcoin/bitcoin/blob/master/src/wallet/coinselection.cpp 56 | 57 | self.txos.sort(reverse=True) 58 | 59 | current_value = 0 60 | current_available_value = self.available 61 | current_selection: List[bool] = [] 62 | best_waste = self.cost_of_change 63 | best_selection: List[bool] = [] 64 | 65 | while self.tries < MAXIMUM_TRIES: 66 | self.tries += 1 67 | 68 | backtrack = False 69 | if current_value + current_available_value < self.target or \ 70 | current_value > self.target + self.cost_of_change: 71 | backtrack = True 72 | elif current_value >= self.target: 73 | new_waste = current_value - self.target 74 | if new_waste <= best_waste: 75 | best_waste = new_waste 76 | best_selection = current_selection[:] 77 | backtrack = True 78 | 79 | if backtrack: 80 | while current_selection and not current_selection[-1]: 81 | current_selection.pop() 82 | current_available_value += self.txos[len(current_selection)].effective_amount 83 | 84 | if not current_selection: 85 | break 86 | 87 | current_selection[-1] = False 88 | utxo = self.txos[len(current_selection) - 1] 89 | current_value -= utxo.effective_amount 90 | 91 | else: 92 | utxo = self.txos[len(current_selection)] 93 | current_available_value -= utxo.effective_amount 94 | previous_utxo = self.txos[len(current_selection) - 1] if current_selection else None 95 | if current_selection and not current_selection[-1] and previous_utxo and \ 96 | utxo.effective_amount == previous_utxo.effective_amount and \ 97 | utxo.fee == previous_utxo.fee: 98 | current_selection.append(False) 99 | else: 100 | current_selection.append(True) 101 | current_value += utxo.effective_amount 102 | 103 | if best_selection: 104 | self.exact_match = True 105 | return [ 106 | self.txos[i] for i, include in enumerate(best_selection) if include 107 | ] 108 | 109 | return [] 110 | 111 | @strategy 112 | def closest_match(self) -> List[basetransaction.BaseOutputEffectiveAmountEstimator]: 113 | """ Pick one UTXOs that is larger than the target but with the smallest change. """ 114 | target = self.target + self.cost_of_change 115 | smallest_change = None 116 | best_match = None 117 | for txo in self.txos: 118 | if txo.effective_amount >= target: 119 | change = txo.effective_amount - target 120 | if smallest_change is None or change < smallest_change: 121 | smallest_change, best_match = change, txo 122 | return [best_match] if best_match else [] 123 | 124 | @strategy 125 | def random_draw(self) -> List[basetransaction.BaseOutputEffectiveAmountEstimator]: 126 | """ Accumulate UTXOs at random until there is enough to cover the target. """ 127 | target = self.target + self.cost_of_change 128 | self.random.shuffle(self.txos, self.random.random) 129 | selection = [] 130 | amount = 0 131 | for coin in self.txos: 132 | selection.append(coin) 133 | amount += coin.effective_amount 134 | if amount >= target: 135 | return selection 136 | return [] 137 | -------------------------------------------------------------------------------- /torba/client/constants.py: -------------------------------------------------------------------------------- 1 | NULL_HASH32 = b'\x00'*32 2 | 3 | CENT = 1000000 4 | COIN = 100*CENT 5 | 6 | TIMEOUT = 30.0 7 | -------------------------------------------------------------------------------- /torba/client/errors.py: -------------------------------------------------------------------------------- 1 | class InsufficientFundsError(Exception): 2 | pass 3 | -------------------------------------------------------------------------------- /torba/client/hash.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016-2017, Neil Booth 2 | # Copyright (c) 2018, LBRY Inc. 3 | # 4 | # All rights reserved. 5 | # 6 | # See the file "LICENCE" for information about the copyright 7 | # and warranty status of this software. 8 | 9 | """ Cryptography hash functions and related classes. """ 10 | 11 | import os 12 | import base64 13 | import hashlib 14 | import hmac 15 | import typing 16 | from binascii import hexlify, unhexlify 17 | from cryptography.hazmat.primitives.kdf.scrypt import Scrypt 18 | from cryptography.hazmat.primitives.ciphers import Cipher, modes 19 | from cryptography.hazmat.primitives.ciphers.algorithms import AES 20 | from cryptography.hazmat.primitives.padding import PKCS7 21 | from cryptography.hazmat.backends import default_backend 22 | 23 | from torba.client.util import bytes_to_int, int_to_bytes 24 | from torba.client.constants import NULL_HASH32 25 | 26 | 27 | class TXRef: 28 | 29 | __slots__ = '_id', '_hash' 30 | 31 | def __init__(self): 32 | self._id = None 33 | self._hash = None 34 | 35 | @property 36 | def id(self): 37 | return self._id 38 | 39 | @property 40 | def hash(self): 41 | return self._hash 42 | 43 | @property 44 | def height(self): 45 | return -1 46 | 47 | @property 48 | def is_null(self): 49 | return self.hash == NULL_HASH32 50 | 51 | 52 | class TXRefImmutable(TXRef): 53 | 54 | __slots__ = ('_height',) 55 | 56 | def __init__(self): 57 | super().__init__() 58 | self._height = -1 59 | 60 | @classmethod 61 | def from_hash(cls, tx_hash: bytes, height: int) -> 'TXRefImmutable': 62 | ref = cls() 63 | ref._hash = tx_hash 64 | ref._id = hexlify(tx_hash[::-1]).decode() 65 | ref._height = height 66 | return ref 67 | 68 | @classmethod 69 | def from_id(cls, tx_id: str, height: int) -> 'TXRefImmutable': 70 | ref = cls() 71 | ref._id = tx_id 72 | ref._hash = unhexlify(tx_id)[::-1] 73 | ref._height = height 74 | return ref 75 | 76 | @property 77 | def height(self): 78 | return self._height 79 | 80 | 81 | def sha256(x): 82 | """ Simple wrapper of hashlib sha256. """ 83 | return hashlib.sha256(x).digest() 84 | 85 | 86 | def sha512(x): 87 | """ Simple wrapper of hashlib sha512. """ 88 | return hashlib.sha512(x).digest() 89 | 90 | 91 | def ripemd160(x): 92 | """ Simple wrapper of hashlib ripemd160. """ 93 | h = hashlib.new('ripemd160') 94 | h.update(x) 95 | return h.digest() 96 | 97 | 98 | def double_sha256(x): 99 | """ SHA-256 of SHA-256, as used extensively in bitcoin. """ 100 | return sha256(sha256(x)) 101 | 102 | 103 | def hmac_sha512(key, msg): 104 | """ Use SHA-512 to provide an HMAC. """ 105 | return hmac.new(key, msg, hashlib.sha512).digest() 106 | 107 | 108 | def hash160(x): 109 | """ RIPEMD-160 of SHA-256. 110 | Used to make bitcoin addresses from pubkeys. """ 111 | return ripemd160(sha256(x)) 112 | 113 | 114 | def hash_to_hex_str(x): 115 | """ Convert a big-endian binary hash to displayed hex string. 116 | Display form of a binary hash is reversed and converted to hex. """ 117 | return hexlify(reversed(x)) 118 | 119 | 120 | def hex_str_to_hash(x): 121 | """ Convert a displayed hex string to a binary hash. """ 122 | return reversed(unhexlify(x)) 123 | 124 | 125 | def aes_encrypt(secret: str, value: str, init_vector: bytes = None) -> str: 126 | if init_vector is not None: 127 | assert len(init_vector) == 16 128 | else: 129 | init_vector = os.urandom(16) 130 | key = double_sha256(secret.encode()) 131 | encryptor = Cipher(AES(key), modes.CBC(init_vector), default_backend()).encryptor() 132 | padder = PKCS7(AES.block_size).padder() 133 | padded_data = padder.update(value.encode()) + padder.finalize() 134 | encrypted_data = encryptor.update(padded_data) + encryptor.finalize() 135 | return base64.b64encode(init_vector + encrypted_data).decode() 136 | 137 | 138 | def aes_decrypt(secret: str, value: str) -> typing.Tuple[str, bytes]: 139 | data = base64.b64decode(value.encode()) 140 | key = double_sha256(secret.encode()) 141 | init_vector, data = data[:16], data[16:] 142 | decryptor = Cipher(AES(key), modes.CBC(init_vector), default_backend()).decryptor() 143 | unpadder = PKCS7(AES.block_size).unpadder() 144 | result = unpadder.update(decryptor.update(data)) + unpadder.finalize() 145 | return result.decode(), init_vector 146 | 147 | 148 | def better_aes_encrypt(secret: str, value: bytes) -> bytes: 149 | init_vector = os.urandom(16) 150 | key = scrypt(secret.encode(), salt=init_vector) 151 | encryptor = Cipher(AES(key), modes.CBC(init_vector), default_backend()).encryptor() 152 | padder = PKCS7(AES.block_size).padder() 153 | padded_data = padder.update(value) + padder.finalize() 154 | encrypted_data = encryptor.update(padded_data) + encryptor.finalize() 155 | return base64.b64encode(b's:8192:16:1:' + init_vector + encrypted_data) 156 | 157 | 158 | def better_aes_decrypt(secret: str, value: bytes) -> bytes: 159 | data = base64.b64decode(value) 160 | _, scryp_n, scrypt_r, scrypt_p, data = data.split(b':', maxsplit=4) 161 | init_vector, data = data[:16], data[16:] 162 | key = scrypt(secret.encode(), init_vector, int(scryp_n), int(scrypt_r), int(scrypt_p)) 163 | decryptor = Cipher(AES(key), modes.CBC(init_vector), default_backend()).decryptor() 164 | unpadder = PKCS7(AES.block_size).unpadder() 165 | return unpadder.update(decryptor.update(data)) + unpadder.finalize() 166 | 167 | 168 | def scrypt(passphrase, salt, scrypt_n=1<<13, scrypt_r=16, scrypt_p=1): 169 | kdf = Scrypt(salt, length=32, n=scrypt_n, r=scrypt_r, p=scrypt_p, backend=default_backend()) 170 | return kdf.derive(passphrase) 171 | 172 | 173 | class Base58Error(Exception): 174 | """ Exception used for Base58 errors. """ 175 | 176 | 177 | class Base58: 178 | """ Class providing base 58 functionality. """ 179 | 180 | chars = u'123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' 181 | assert len(chars) == 58 182 | char_map = {c: n for n, c in enumerate(chars)} 183 | 184 | @classmethod 185 | def char_value(cls, c): 186 | val = cls.char_map.get(c) 187 | if val is None: 188 | raise Base58Error('invalid base 58 character "{}"'.format(c)) 189 | return val 190 | 191 | @classmethod 192 | def decode(cls, txt): 193 | """ Decodes txt into a big-endian bytearray. """ 194 | if isinstance(txt, memoryview): 195 | txt = str(txt) 196 | 197 | if isinstance(txt, bytes): 198 | txt = txt.decode() 199 | 200 | if not isinstance(txt, str): 201 | raise TypeError('a string is required') 202 | 203 | if not txt: 204 | raise Base58Error('string cannot be empty') 205 | 206 | value = 0 207 | for c in txt: 208 | value = value * 58 + cls.char_value(c) 209 | 210 | result = int_to_bytes(value) 211 | 212 | # Prepend leading zero bytes if necessary 213 | count = 0 214 | for c in txt: 215 | if c != u'1': 216 | break 217 | count += 1 218 | if count: 219 | result = bytes((0,)) * count + result 220 | 221 | return result 222 | 223 | @classmethod 224 | def encode(cls, be_bytes): 225 | """Converts a big-endian bytearray into a base58 string.""" 226 | value = bytes_to_int(be_bytes) 227 | 228 | txt = u'' 229 | while value: 230 | value, mod = divmod(value, 58) 231 | txt += cls.chars[mod] 232 | 233 | for byte in be_bytes: 234 | if byte != 0: 235 | break 236 | txt += u'1' 237 | 238 | return txt[::-1] 239 | 240 | @classmethod 241 | def decode_check(cls, txt, hash_fn=double_sha256): 242 | """ Decodes a Base58Check-encoded string to a payload. The version prefixes it. """ 243 | be_bytes = cls.decode(txt) 244 | result, check = be_bytes[:-4], be_bytes[-4:] 245 | if check != hash_fn(result)[:4]: 246 | raise Base58Error('invalid base 58 checksum for {}'.format(txt)) 247 | return result 248 | 249 | @classmethod 250 | def encode_check(cls, payload, hash_fn=double_sha256): 251 | """ Encodes a payload bytearray (which includes the version byte(s)) 252 | into a Base58Check string.""" 253 | be_bytes = payload + hash_fn(payload)[:4] 254 | return cls.encode(be_bytes) 255 | -------------------------------------------------------------------------------- /torba/client/mnemonic.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2014 Thomas Voegtlin 2 | # Copyright (C) 2018 LBRY Inc. 3 | 4 | import hmac 5 | import math 6 | import hashlib 7 | import importlib 8 | import unicodedata 9 | import string 10 | from binascii import hexlify 11 | from secrets import randbelow 12 | 13 | import pbkdf2 14 | 15 | from torba.client.hash import hmac_sha512 16 | from torba.client.words import english 17 | 18 | # The hash of the mnemonic seed must begin with this 19 | SEED_PREFIX = b'01' # Standard wallet 20 | SEED_PREFIX_2FA = b'101' # Two-factor authentication 21 | SEED_PREFIX_SW = b'100' # Segwit wallet 22 | 23 | # http://www.asahi-net.or.jp/~ax2s-kmtn/ref/unicode/e_asia.html 24 | CJK_INTERVALS = [ 25 | (0x4E00, 0x9FFF, 'CJK Unified Ideographs'), 26 | (0x3400, 0x4DBF, 'CJK Unified Ideographs Extension A'), 27 | (0x20000, 0x2A6DF, 'CJK Unified Ideographs Extension B'), 28 | (0x2A700, 0x2B73F, 'CJK Unified Ideographs Extension C'), 29 | (0x2B740, 0x2B81F, 'CJK Unified Ideographs Extension D'), 30 | (0xF900, 0xFAFF, 'CJK Compatibility Ideographs'), 31 | (0x2F800, 0x2FA1D, 'CJK Compatibility Ideographs Supplement'), 32 | (0x3190, 0x319F, 'Kanbun'), 33 | (0x2E80, 0x2EFF, 'CJK Radicals Supplement'), 34 | (0x2F00, 0x2FDF, 'CJK Radicals'), 35 | (0x31C0, 0x31EF, 'CJK Strokes'), 36 | (0x2FF0, 0x2FFF, 'Ideographic Description Characters'), 37 | (0xE0100, 0xE01EF, 'Variation Selectors Supplement'), 38 | (0x3100, 0x312F, 'Bopomofo'), 39 | (0x31A0, 0x31BF, 'Bopomofo Extended'), 40 | (0xFF00, 0xFFEF, 'Halfwidth and Fullwidth Forms'), 41 | (0x3040, 0x309F, 'Hiragana'), 42 | (0x30A0, 0x30FF, 'Katakana'), 43 | (0x31F0, 0x31FF, 'Katakana Phonetic Extensions'), 44 | (0x1B000, 0x1B0FF, 'Kana Supplement'), 45 | (0xAC00, 0xD7AF, 'Hangul Syllables'), 46 | (0x1100, 0x11FF, 'Hangul Jamo'), 47 | (0xA960, 0xA97F, 'Hangul Jamo Extended A'), 48 | (0xD7B0, 0xD7FF, 'Hangul Jamo Extended B'), 49 | (0x3130, 0x318F, 'Hangul Compatibility Jamo'), 50 | (0xA4D0, 0xA4FF, 'Lisu'), 51 | (0x16F00, 0x16F9F, 'Miao'), 52 | (0xA000, 0xA48F, 'Yi Syllables'), 53 | (0xA490, 0xA4CF, 'Yi Radicals'), 54 | ] 55 | 56 | 57 | def is_cjk(c): 58 | n = ord(c) 59 | for start, end, _ in CJK_INTERVALS: 60 | if start <= n <= end: 61 | return True 62 | return False 63 | 64 | 65 | def normalize_text(seed): 66 | seed = unicodedata.normalize('NFKD', seed) 67 | seed = seed.lower() 68 | # remove accents 69 | seed = u''.join([c for c in seed if not unicodedata.combining(c)]) 70 | # normalize whitespaces 71 | seed = u' '.join(seed.split()) 72 | # remove whitespaces between CJK 73 | seed = u''.join([ 74 | seed[i] for i in range(len(seed)) 75 | if not (seed[i] in string.whitespace and is_cjk(seed[i-1]) and is_cjk(seed[i+1])) 76 | ]) 77 | return seed 78 | 79 | 80 | def load_words(language_name): 81 | if language_name == 'english': 82 | return english.words 83 | language_module = importlib.import_module('torba.words.'+language_name) 84 | return list(map( 85 | lambda s: unicodedata.normalize('NFKD', s), 86 | language_module.words 87 | )) 88 | 89 | 90 | LANGUAGE_NAMES = { 91 | 'en': 'english', 92 | 'es': 'spanish', 93 | 'ja': 'japanese', 94 | 'pt': 'portuguese', 95 | 'zh': 'chinese_simplified' 96 | } 97 | 98 | 99 | class Mnemonic: 100 | # Seed derivation no longer follows BIP39 101 | # Mnemonic phrase uses a hash based checksum, instead of a words-dependent checksum 102 | 103 | def __init__(self, lang='en'): 104 | language_name = LANGUAGE_NAMES.get(lang, 'english') 105 | self.words = load_words(language_name) 106 | 107 | @staticmethod 108 | def mnemonic_to_seed(mnemonic, passphrase=u''): 109 | pbkdf2_rounds = 2048 110 | mnemonic = normalize_text(mnemonic) 111 | passphrase = normalize_text(passphrase) 112 | return pbkdf2.PBKDF2( 113 | mnemonic, passphrase, iterations=pbkdf2_rounds, macmodule=hmac, digestmodule=hashlib.sha512 114 | ).read(64) 115 | 116 | def mnemonic_encode(self, i): 117 | n = len(self.words) 118 | words = [] 119 | while i: 120 | x = i%n 121 | i = i//n 122 | words.append(self.words[x]) 123 | return ' '.join(words) 124 | 125 | def mnemonic_decode(self, seed): 126 | n = len(self.words) 127 | words = seed.split() 128 | i = 0 129 | while words: 130 | word = words.pop() 131 | k = self.words.index(word) 132 | i = i*n + k 133 | return i 134 | 135 | def make_seed(self, prefix=SEED_PREFIX, num_bits=132): 136 | # increase num_bits in order to obtain a uniform distribution for the last word 137 | bpw = math.log(len(self.words), 2) 138 | # rounding 139 | n = int(math.ceil(num_bits/bpw) * bpw) 140 | entropy = 1 141 | while 0 < entropy < pow(2, n - bpw): 142 | # try again if seed would not contain enough words 143 | entropy = randbelow(pow(2, n)) 144 | nonce = 0 145 | while True: 146 | nonce += 1 147 | i = entropy + nonce 148 | seed = self.mnemonic_encode(i) 149 | if i != self.mnemonic_decode(seed): 150 | raise Exception('Cannot extract same entropy from mnemonic!') 151 | if is_new_seed(seed, prefix): 152 | break 153 | return seed 154 | 155 | 156 | def is_new_seed(seed, prefix): 157 | seed = normalize_text(seed) 158 | seed_hash = hexlify(hmac_sha512(b"Seed version", seed.encode('utf8'))) 159 | return seed_hash.startswith(prefix) 160 | -------------------------------------------------------------------------------- /torba/client/util.py: -------------------------------------------------------------------------------- 1 | import re 2 | from binascii import unhexlify, hexlify 3 | from typing import TypeVar, Sequence, Optional 4 | from torba.client.constants import COIN 5 | 6 | 7 | def coins_to_satoshis(coins): 8 | if not isinstance(coins, str): 9 | raise ValueError("{coins} must be a string") 10 | result = re.search(r'^(\d{1,10})\.(\d{1,8})#39;, coins) 11 | if result is not None: 12 | whole, fractional = result.groups() 13 | return int(whole+fractional.ljust(8, "0")) 14 | raise ValueError("'{lbc}' is not a valid coin decimal") 15 | 16 | 17 | def satoshis_to_coins(satoshis): 18 | coins = '{:.8f}'.format(satoshis / COIN).rstrip('0') 19 | if coins.endswith('.'): 20 | return coins+'0' 21 | else: 22 | return coins 23 | 24 | 25 | T = TypeVar('T') 26 | 27 | 28 | class ReadOnlyList(Sequence[T]): 29 | 30 | def __init__(self, lst): 31 | self.lst = lst 32 | 33 | def __getitem__(self, key): 34 | return self.lst[key] 35 | 36 | def __len__(self) -> int: 37 | return len(self.lst) 38 | 39 | 40 | def subclass_tuple(name, base): 41 | return type(name, (base,), {'__slots__': ()}) 42 | 43 | 44 | class cachedproperty: 45 | 46 | def __init__(self, f): 47 | self.f = f 48 | 49 | def __get__(self, obj, objtype): 50 | obj = obj or objtype 51 | value = self.f(obj) 52 | setattr(obj, self.f.__name__, value) 53 | return value 54 | 55 | 56 | def bytes_to_int(be_bytes): 57 | """ Interprets a big-endian sequence of bytes as an integer. """ 58 | return int(hexlify(be_bytes), 16) 59 | 60 | 61 | def int_to_bytes(value): 62 | """ Converts an integer to a big-endian sequence of bytes. """ 63 | length = (value.bit_length() + 7) // 8 64 | s = '%x' % value 65 | return unhexlify(('0' * (len(s) % 2) + s).zfill(length * 2)) 66 | 67 | 68 | class ArithUint256: 69 | # https://github.com/bitcoin/bitcoin/blob/master/src/arith_uint256.cpp 70 | 71 | __slots__ = '_value', '_compact' 72 | 73 | def __init__(self, value: int) -> None: 74 | self._value = value 75 | self._compact: Optional[int] = None 76 | 77 | @classmethod 78 | def from_compact(cls, compact) -> 'ArithUint256': 79 | size = compact >> 24 80 | word = compact & 0x007fffff 81 | if size <= 3: 82 | return cls(word >> 8 * (3 - size)) 83 | else: 84 | return cls(word << 8 * (size - 3)) 85 | 86 | @property 87 | def value(self) -> int: 88 | return self._value 89 | 90 | @property 91 | def compact(self) -> int: 92 | if self._compact is None: 93 | self._compact = self._calculate_compact() 94 | return self._compact 95 | 96 | @property 97 | def negative(self) -> int: 98 | return self._calculate_compact(negative=True) 99 | 100 | @property 101 | def bits(self) -> int: 102 | """ Returns the position of the highest bit set plus one. """ 103 | bits = bin(self._value)[2:] 104 | for i, d in enumerate(bits): 105 | if d: 106 | return (len(bits) - i) + 1 107 | return 0 108 | 109 | @property 110 | def low64(self) -> int: 111 | return self._value & 0xffffffffffffffff 112 | 113 | def _calculate_compact(self, negative=False) -> int: 114 | size = (self.bits + 7) // 8 115 | if size <= 3: 116 | compact = self.low64 << 8 * (3 - size) 117 | else: 118 | compact = ArithUint256(self._value >> 8 * (size - 3)).low64 119 | # The 0x00800000 bit denotes the sign. 120 | # Thus, if it is already set, divide the mantissa by 256 and increase the exponent. 121 | if compact & 0x00800000: 122 | compact >>= 8 123 | size += 1 124 | assert (compact & ~0x007fffff) == 0 125 | assert size < 256 126 | compact |= size << 24 127 | if negative and compact & 0x007fffff: 128 | compact |= 0x00800000 129 | return compact 130 | 131 | def __mul__(self, x): 132 | # Take the mod because we are limited to an unsigned 256 bit number 133 | return ArithUint256((self._value * x) % 2 ** 256) 134 | 135 | def __truediv__(self, x): 136 | return ArithUint256(int(self._value / x)) 137 | 138 | def __gt__(self, other): 139 | return self._value > other 140 | 141 | def __lt__(self, other): 142 | return self._value < other 143 | -------------------------------------------------------------------------------- /torba/client/wallet.py: -------------------------------------------------------------------------------- 1 | import os 2 | import stat 3 | import json 4 | import zlib 5 | import typing 6 | from typing import Sequence, MutableSequence 7 | from hashlib import sha256 8 | from operator import attrgetter 9 | from torba.client.hash import better_aes_encrypt, better_aes_decrypt 10 | 11 | if typing.TYPE_CHECKING: 12 | from torba.client import basemanager, baseaccount, baseledger 13 | 14 | 15 | class Wallet: 16 | """ The primary role of Wallet is to encapsulate a collection 17 | of accounts (seed/private keys) and the spending rules / settings 18 | for the coins attached to those accounts. Wallets are represented 19 | by physical files on the filesystem. 20 | """ 21 | 22 | def __init__(self, name: str = 'Wallet', accounts: MutableSequence['baseaccount.BaseAccount'] = None, 23 | storage: 'WalletStorage' = None) -> None: 24 | self.name = name 25 | self.accounts = accounts or [] 26 | self.storage = storage or WalletStorage() 27 | 28 | def add_account(self, account): 29 | self.accounts.append(account) 30 | 31 | def generate_account(self, ledger: 'baseledger.BaseLedger') -> 'baseaccount.BaseAccount': 32 | return ledger.account_class.generate(ledger, self) 33 | 34 | @classmethod 35 | def from_storage(cls, storage: 'WalletStorage', manager: 'basemanager.BaseWalletManager') -> 'Wallet': 36 | json_dict = storage.read() 37 | wallet = cls( 38 | name=json_dict.get('name', 'Wallet'), 39 | storage=storage 40 | ) 41 | account_dicts: Sequence[dict] = json_dict.get('accounts', []) 42 | for account_dict in account_dicts: 43 | ledger = manager.get_or_create_ledger(account_dict['ledger']) 44 | ledger.account_class.from_dict(ledger, wallet, account_dict) 45 | return wallet 46 | 47 | def to_dict(self): 48 | return { 49 | 'version': WalletStorage.LATEST_VERSION, 50 | 'name': self.name, 51 | 'accounts': [a.to_dict() for a in self.accounts] 52 | } 53 | 54 | def save(self): 55 | self.storage.write(self.to_dict()) 56 | 57 | @property 58 | def default_account(self): 59 | for account in self.accounts: 60 | return account 61 | 62 | @property 63 | def hash(self) -> bytes: 64 | h = sha256() 65 | for account in sorted(self.accounts, key=attrgetter('id')): 66 | h.update(account.hash) 67 | return h.digest() 68 | 69 | def pack(self, password): 70 | new_data = json.dumps(self.to_dict()) 71 | new_data_compressed = zlib.compress(new_data.encode()) 72 | return better_aes_encrypt(password, new_data_compressed) 73 | 74 | @classmethod 75 | def unpack(cls, password, encrypted): 76 | decrypted = better_aes_decrypt(password, encrypted) 77 | decompressed = zlib.decompress(decrypted) 78 | return json.loads(decompressed) 79 | 80 | 81 | class WalletStorage: 82 | 83 | LATEST_VERSION = 1 84 | 85 | def __init__(self, path=None, default=None): 86 | self.path = path 87 | self._default = default or { 88 | 'version': self.LATEST_VERSION, 89 | 'name': 'My Wallet', 90 | 'accounts': [] 91 | } 92 | 93 | def read(self): 94 | if self.path and os.path.exists(self.path): 95 | with open(self.path, 'r') as f: 96 | json_data = f.read() 97 | json_dict = json.loads(json_data) 98 | if json_dict.get('version') == self.LATEST_VERSION and \ 99 | set(json_dict) == set(self._default): 100 | return json_dict 101 | else: 102 | return self.upgrade(json_dict) 103 | else: 104 | return self._default.copy() 105 | 106 | def upgrade(self, json_dict): 107 | json_dict = json_dict.copy() 108 | version = json_dict.pop('version', -1) 109 | if version == -1: 110 | pass 111 | upgraded = self._default.copy() 112 | upgraded.update(json_dict) 113 | return json_dict 114 | 115 | def write(self, json_dict): 116 | 117 | json_data = json.dumps(json_dict, indent=4, sort_keys=True) 118 | if self.path is None: 119 | return json_data 120 | 121 | temp_path = "%s.tmp.%s" % (self.path, os.getpid()) 122 | with open(temp_path, "w") as f: 123 | f.write(json_data) 124 | f.flush() 125 | os.fsync(f.fileno()) 126 | 127 | if os.path.exists(self.path): 128 | mode = os.stat(self.path).st_mode 129 | else: 130 | mode = stat.S_IREAD | stat.S_IWRITE 131 | try: 132 | os.rename(temp_path, self.path) 133 | except Exception: # pylint: disable=broad-except 134 | os.remove(self.path) 135 | os.rename(temp_path, self.path) 136 | os.chmod(self.path, mode) 137 | -------------------------------------------------------------------------------- /torba/client/words/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lbryio/torba/190304344c0ff68f8a24cf50272307a11bf7f62b/torba/client/words/__init__.py -------------------------------------------------------------------------------- /torba/coin/__init__.py: -------------------------------------------------------------------------------- 1 | __path__: str = __import__('pkgutil').extend_path(__path__, __name__) 2 | -------------------------------------------------------------------------------- /torba/coin/bitcoincash.py: -------------------------------------------------------------------------------- 1 | __node_daemon__ = 'bitcoind' 2 | __node_cli__ = 'bitcoin-cli' 3 | __node_bin__ = 'bitcoin-abc-0.17.2/bin' 4 | __node_url__ = ( 5 | 'https://download.bitcoinabc.org/0.17.2/linux/bitcoin-abc-0.17.2-x86_64-linux-gnu.tar.gz' 6 | ) 7 | __spvserver__ = 'torba.server.coins.BitcoinCashRegtest' 8 | 9 | from binascii import unhexlify 10 | from torba.client.baseledger import BaseLedger 11 | from torba.client.basetransaction import BaseTransaction 12 | from .bitcoinsegwit import MainHeaders, UnverifiedHeaders 13 | 14 | 15 | class Transaction(BaseTransaction): 16 | 17 | def signature_hash_type(self, hash_type): 18 | return hash_type | 0x40 19 | 20 | 21 | class MainNetLedger(BaseLedger): 22 | name = 'BitcoinCash' 23 | symbol = 'BCH' 24 | network_name = 'mainnet' 25 | 26 | headers_class = MainHeaders 27 | transaction_class = Transaction 28 | 29 | pubkey_address_prefix = bytes((0,)) 30 | script_address_prefix = bytes((5,)) 31 | extended_public_key_prefix = unhexlify('0488b21e') 32 | extended_private_key_prefix = unhexlify('0488ade4') 33 | 34 | default_fee_per_byte = 50 35 | 36 | 37 | class RegTestLedger(MainNetLedger): 38 | headers_class = UnverifiedHeaders 39 | network_name = 'regtest' 40 | 41 | pubkey_address_prefix = bytes((111,)) 42 | script_address_prefix = bytes((196,)) 43 | extended_public_key_prefix = unhexlify('043587cf') 44 | extended_private_key_prefix = unhexlify('04358394') 45 | 46 | max_target = 0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff 47 | genesis_hash = '0f9188f13cb7b2c71f2a335e3a4fc328bf5beb436012afca590b1a11466e2206' 48 | genesis_bits = 0x207fffff 49 | target_timespan = 1 50 | -------------------------------------------------------------------------------- /torba/coin/bitcoinsegwit.py: -------------------------------------------------------------------------------- 1 | __node_daemon__ = 'bitcoind' 2 | __node_cli__ = 'bitcoin-cli' 3 | __node_bin__ = 'bitcoin-0.16.3/bin' 4 | __node_url__ = ( 5 | 'https://bitcoin.org/bin/bitcoin-core-0.16.3/bitcoin-0.16.3-x86_64-linux-gnu.tar.gz' 6 | ) 7 | __spvserver__ = 'torba.server.coins.BitcoinSegwitRegtest' 8 | 9 | import struct 10 | from typing import Optional 11 | from binascii import hexlify, unhexlify 12 | from torba.client.baseledger import BaseLedger 13 | from torba.client.baseheader import BaseHeaders, ArithUint256 14 | 15 | 16 | class MainHeaders(BaseHeaders): 17 | header_size = 80 18 | chunk_size = 2016 19 | max_target = 0x00000000ffffffffffffffffffffffffffffffffffffffffffffffffffffffff 20 | genesis_hash: Optional[bytes] = b'000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f' 21 | target_timespan = 14 * 24 * 60 * 60 22 | 23 | @staticmethod 24 | def serialize(header: dict) -> bytes: 25 | return b''.join([ 26 | struct.pack('<I', header['version']), 27 | unhexlify(header['prev_block_hash'])[::-1], 28 | unhexlify(header['merkle_root'])[::-1], 29 | struct.pack('<III', header['timestamp'], header['bits'], header['nonce']) 30 | ]) 31 | 32 | @staticmethod 33 | def deserialize(height, header): 34 | version, = struct.unpack('<I', header[:4]) 35 | timestamp, bits, nonce = struct.unpack('<III', header[68:80]) 36 | return { 37 | 'block_height': height, 38 | 'version': version, 39 | 'prev_block_hash': hexlify(header[4:36][::-1]), 40 | 'merkle_root': hexlify(header[36:68][::-1]), 41 | 'timestamp': timestamp, 42 | 'bits': bits, 43 | 'nonce': nonce 44 | } 45 | 46 | def get_next_chunk_target(self, chunk: int) -> ArithUint256: 47 | if chunk == -1: 48 | return ArithUint256(self.max_target) 49 | previous = self[chunk * 2016] 50 | current = self[chunk * 2016 + 2015] 51 | actual_timespan = current['timestamp'] - previous['timestamp'] 52 | actual_timespan = max(actual_timespan, int(self.target_timespan / 4)) 53 | actual_timespan = min(actual_timespan, self.target_timespan * 4) 54 | target = ArithUint256.from_compact(current['bits']) 55 | new_target = min(ArithUint256(self.max_target), (target * actual_timespan) / self.target_timespan) 56 | return new_target 57 | 58 | 59 | class MainNetLedger(BaseLedger): 60 | name = 'BitcoinSegwit' 61 | symbol = 'BTC' 62 | network_name = 'mainnet' 63 | headers_class = MainHeaders 64 | 65 | pubkey_address_prefix = bytes((0,)) 66 | script_address_prefix = bytes((5,)) 67 | extended_public_key_prefix = unhexlify('0488b21e') 68 | extended_private_key_prefix = unhexlify('0488ade4') 69 | 70 | default_fee_per_byte = 50 71 | 72 | 73 | class UnverifiedHeaders(MainHeaders): 74 | max_target = 0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff 75 | genesis_hash = None 76 | validate_difficulty = False 77 | 78 | 79 | class RegTestLedger(MainNetLedger): 80 | network_name = 'regtest' 81 | headers_class = UnverifiedHeaders 82 | 83 | pubkey_address_prefix = bytes((111,)) 84 | script_address_prefix = bytes((196,)) 85 | extended_public_key_prefix = unhexlify('043587cf') 86 | extended_private_key_prefix = unhexlify('04358394') 87 | -------------------------------------------------------------------------------- /torba/orchstr8/__init__.py: -------------------------------------------------------------------------------- 1 | from .node import Conductor 2 | from .service import ConductorService 3 | -------------------------------------------------------------------------------- /torba/orchstr8/cli.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import argparse 3 | import asyncio 4 | import aiohttp 5 | 6 | from torba.orchstr8.node import Conductor, get_ledger_from_environment, get_blockchain_node_from_ledger 7 | from torba.orchstr8.service import ConductorService 8 | 9 | 10 | def get_argument_parser(): 11 | parser = argparse.ArgumentParser( 12 | prog="torba" 13 | ) 14 | subparsers = parser.add_subparsers(dest='command', help='sub-command help') 15 | 16 | subparsers.add_parser("gui", help="Start Qt GUI.") 17 | 18 | subparsers.add_parser("download", help="Download blockchain node binary.") 19 | 20 | start = subparsers.add_parser("start", help="Start orchstr8 service.") 21 | start.add_argument("--blockchain", help="Hostname to start blockchain node.") 22 | start.add_argument("--spv", help="Hostname to start SPV server.") 23 | start.add_argument("--wallet", help="Hostname to start wallet daemon.") 24 | 25 | generate = subparsers.add_parser("generate", help="Call generate method on running orchstr8 instance.") 26 | generate.add_argument("blocks", type=int, help="Number of blocks to generate") 27 | 28 | subparsers.add_parser("transfer", help="Call transfer method on running orchstr8 instance.") 29 | return parser 30 | 31 | 32 | async def run_remote_command(command, **kwargs): 33 | async with aiohttp.ClientSession() as session: 34 | async with session.post('http://localhost:7954/'+command, data=kwargs) as resp: 35 | print(resp.status) 36 | print(await resp.text()) 37 | 38 | 39 | def main(): 40 | parser = get_argument_parser() 41 | args = parser.parse_args() 42 | command = getattr(args, 'command', 'help') 43 | 44 | if command == 'gui': 45 | from torba.workbench import main as start_app # pylint: disable=E0611,E0401 46 | return start_app() 47 | 48 | loop = asyncio.get_event_loop() 49 | asyncio.set_event_loop(loop) 50 | ledger = get_ledger_from_environment() 51 | 52 | if command == 'download': 53 | logging.getLogger('blockchain').setLevel(logging.INFO) 54 | get_blockchain_node_from_ledger(ledger).ensure() 55 | 56 | elif command == 'generate': 57 | loop.run_until_complete(run_remote_command( 58 | 'generate', blocks=args.blocks 59 | )) 60 | 61 | elif command == 'start': 62 | 63 | conductor = Conductor() 64 | if getattr(args, 'blockchain', False): 65 | conductor.blockchain_node.hostname = args.blockchain 66 | loop.run_until_complete(conductor.start_blockchain()) 67 | if getattr(args, 'spv', False): 68 | conductor.spv_node.hostname = args.spv 69 | loop.run_until_complete(conductor.start_spv()) 70 | if getattr(args, 'wallet', False): 71 | conductor.wallet_node.hostname = args.wallet 72 | loop.run_until_complete(conductor.start_wallet()) 73 | 74 | service = ConductorService(conductor, loop) 75 | loop.run_until_complete(service.start()) 76 | 77 | try: 78 | print('========== Orchstr8 API Service Started ========') 79 | loop.run_forever() 80 | except KeyboardInterrupt: 81 | pass 82 | finally: 83 | loop.run_until_complete(service.stop()) 84 | loop.run_until_complete(conductor.stop()) 85 | 86 | loop.close() 87 | 88 | else: 89 | parser.print_help() 90 | 91 | 92 | if __name__ == "__main__": 93 | main() 94 | -------------------------------------------------------------------------------- /torba/orchstr8/service.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | from aiohttp.web import Application, WebSocketResponse, json_response 4 | from aiohttp.http_websocket import WSMsgType, WSCloseCode 5 | 6 | from torba.client.util import satoshis_to_coins 7 | from .node import Conductor, set_logging 8 | 9 | 10 | PORT = 7954 11 | 12 | 13 | class WebSocketLogHandler(logging.Handler): 14 | 15 | def __init__(self, send_message): 16 | super().__init__() 17 | self.send_message = send_message 18 | 19 | def emit(self, record): 20 | try: 21 | self.send_message({ 22 | 'type': 'log', 23 | 'name': record.name, 24 | 'message': self.format(record) 25 | }) 26 | except Exception: 27 | self.handleError(record) 28 | 29 | 30 | class ConductorService: 31 | 32 | def __init__(self, stack: Conductor, loop: asyncio.AbstractEventLoop) -> None: 33 | self.stack = stack 34 | self.loop = loop 35 | self.app = Application() 36 | self.app.router.add_post('/start', self.start_stack) 37 | self.app.router.add_post('/generate', self.generate) 38 | self.app.router.add_post('/transfer', self.transfer) 39 | self.app.router.add_post('/balance', self.balance) 40 | self.app.router.add_get('/log', self.log) 41 | self.app['websockets'] = set() 42 | self.app.on_shutdown.append(self.on_shutdown) 43 | self.handler = self.app.make_handler() 44 | self.server = None 45 | 46 | async def start(self): 47 | self.server = await self.loop.create_server( 48 | self.handler, '0.0.0.0', PORT 49 | ) 50 | print('serving on', self.server.sockets[0].getsockname()) 51 | 52 | async def stop(self): 53 | await self.stack.stop() 54 | self.server.close() 55 | await self.server.wait_closed() 56 | await self.app.shutdown() 57 | await self.handler.shutdown(60.0) 58 | await self.app.cleanup() 59 | 60 | async def start_stack(self, _): 61 | set_logging( 62 | self.stack.ledger_module, logging.DEBUG, WebSocketLogHandler(self.send_message) 63 | ) 64 | self.stack.blockchain_started or await self.stack.start_blockchain() 65 | self.send_message({'type': 'service', 'name': 'blockchain', 'port': self.stack.blockchain_node.port}) 66 | self.stack.spv_started or await self.stack.start_spv() 67 | self.send_message({'type': 'service', 'name': 'spv', 'port': self.stack.spv_node.port}) 68 | self.stack.wallet_started or await self.stack.start_wallet() 69 | self.send_message({'type': 'service', 'name': 'wallet', 'port': self.stack.wallet_node.port}) 70 | self.stack.wallet_node.ledger.on_header.listen(self.on_status) 71 | self.stack.wallet_node.ledger.on_transaction.listen(self.on_status) 72 | return json_response({'started': True}) 73 | 74 | async def generate(self, request): 75 | data = await request.post() 76 | blocks = data.get('blocks', 1) 77 | await self.stack.blockchain_node.generate(int(blocks)) 78 | return json_response({'blocks': blocks}) 79 | 80 | async def transfer(self, request): 81 | data = await request.post() 82 | address = data.get('address') 83 | if not address and self.stack.wallet_started: 84 | address = await self.stack.wallet_node.account.receiving.get_or_create_usable_address() 85 | if not address: 86 | raise ValueError("No address was provided.") 87 | amount = data.get('amount', 1) 88 | txid = await self.stack.blockchain_node.send_to_address(address, amount) 89 | if self.stack.wallet_started: 90 | await self.stack.wallet_node.ledger.on_transaction.where( 91 | lambda e: e.tx.id == txid and e.address == address 92 | ) 93 | return json_response({ 94 | 'address': address, 95 | 'amount': amount, 96 | 'txid': txid 97 | }) 98 | 99 | async def balance(self, _): 100 | return json_response({ 101 | 'balance': await self.stack.blockchain_node.get_balance() 102 | }) 103 | 104 | async def log(self, request): 105 | web_socket = WebSocketResponse() 106 | await web_socket.prepare(request) 107 | self.app['websockets'].add(web_socket) 108 | try: 109 | async for msg in web_socket: 110 | if msg.type == WSMsgType.TEXT: 111 | if msg.data == 'close': 112 | await web_socket.close() 113 | elif msg.type == WSMsgType.ERROR: 114 | print('web socket connection closed with exception %s' % 115 | web_socket.exception()) 116 | finally: 117 | self.app['websockets'].remove(web_socket) 118 | return web_socket 119 | 120 | @staticmethod 121 | async def on_shutdown(app): 122 | for web_socket in app['websockets']: 123 | await web_socket.close(code=WSCloseCode.GOING_AWAY, message='Server shutdown') 124 | 125 | async def on_status(self, _): 126 | if not self.app['websockets']: 127 | return 128 | self.send_message({ 129 | 'type': 'status', 130 | 'height': self.stack.wallet_node.ledger.headers.height, 131 | 'balance': satoshis_to_coins(await self.stack.wallet_node.account.get_balance()), 132 | 'miner': await self.stack.blockchain_node.get_balance() 133 | }) 134 | 135 | def send_message(self, msg): 136 | for web_socket in self.app['websockets']: 137 | self.loop.create_task(web_socket.send_json(msg)) 138 | -------------------------------------------------------------------------------- /torba/rpc/__init__.py: -------------------------------------------------------------------------------- 1 | from .framing import * 2 | from .jsonrpc import * 3 | from .socks import * 4 | from .session import * 5 | from .util import * 6 | 7 | __all__ = (framing.__all__ + 8 | jsonrpc.__all__ + 9 | socks.__all__ + 10 | session.__all__ + 11 | util.__all__) 12 | -------------------------------------------------------------------------------- /torba/rpc/framing.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2018, Neil Booth 2 | # 3 | # All rights reserved. 4 | # 5 | # The MIT License (MIT) 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining 8 | # a copy of this software and associated documentation files (the 9 | # "Software"), to deal in the Software without restriction, including 10 | # without limitation the rights to use, copy, modify, merge, publish, 11 | # distribute, sublicense, and/or sell copies of the Software, and to 12 | # permit persons to whom the Software is furnished to do so, subject to 13 | # the following conditions: 14 | # 15 | # The above copyright notice and this permission notice shall be 16 | # included in all copies or substantial portions of the Software. 17 | # 18 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 19 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 20 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 21 | # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 22 | # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 23 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 24 | # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 25 | 26 | """RPC message framing in a byte stream.""" 27 | 28 | __all__ = ('FramerBase', 'NewlineFramer', 'BinaryFramer', 'BitcoinFramer', 29 | 'OversizedPayloadError', 'BadChecksumError', 'BadMagicError') 30 | 31 | from hashlib import sha256 as _sha256 32 | from struct import Struct 33 | from asyncio import Queue 34 | 35 | 36 | class FramerBase(object): 37 | """Abstract base class for a framer. 38 | 39 | A framer breaks an incoming byte stream into protocol messages, 40 | buffering if necesary. It also frames outgoing messages into 41 | a byte stream. 42 | """ 43 | 44 | def frame(self, message): 45 | """Return the framed message.""" 46 | raise NotImplementedError 47 | 48 | def received_bytes(self, data): 49 | """Pass incoming network bytes.""" 50 | raise NotImplementedError 51 | 52 | async def receive_message(self): 53 | """Wait for a complete unframed message to arrive, and return it.""" 54 | raise NotImplementedError 55 | 56 | 57 | class NewlineFramer(FramerBase): 58 | """A framer for a protocol where messages are separated by newlines.""" 59 | 60 | # The default max_size value is motivated by JSONRPC, where a 61 | # normal request will be 250 bytes or less, and a reasonable 62 | # batch may contain 4000 requests. 63 | def __init__(self, max_size=250 * 4000): 64 | """max_size - an anti-DoS measure. If, after processing an incoming 65 | message, buffered data would exceed max_size bytes, that 66 | buffered data is dropped entirely and the framer waits for a 67 | newline character to re-synchronize the stream. 68 | """ 69 | self.max_size = max_size 70 | self.queue = Queue() 71 | self.received_bytes = self.queue.put_nowait 72 | self.synchronizing = False 73 | self.residual = b'' 74 | 75 | def frame(self, message): 76 | return message + b'\n' 77 | 78 | async def receive_message(self): 79 | parts = [] 80 | buffer_size = 0 81 | while True: 82 | part = self.residual 83 | self.residual = b'' 84 | if not part: 85 | part = await self.queue.get() 86 | 87 | npos = part.find(b'\n') 88 | if npos == -1: 89 | parts.append(part) 90 | buffer_size += len(part) 91 | # Ignore over-sized messages; re-synchronize 92 | if buffer_size <= self.max_size: 93 | continue 94 | self.synchronizing = True 95 | raise MemoryError(f'dropping message over {self.max_size:,d} ' 96 | f'bytes and re-synchronizing') 97 | 98 | tail, self.residual = part[:npos], part[npos + 1:] 99 | if self.synchronizing: 100 | self.synchronizing = False 101 | return await self.receive_message() 102 | else: 103 | parts.append(tail) 104 | return b''.join(parts) 105 | 106 | 107 | class ByteQueue(object): 108 | """A producer-comsumer queue. Incoming network data is put as it 109 | arrives, and the consumer calls an async method waiting for data of 110 | a specific length.""" 111 | 112 | def __init__(self): 113 | self.queue = Queue() 114 | self.parts = [] 115 | self.parts_len = 0 116 | self.put_nowait = self.queue.put_nowait 117 | 118 | async def receive(self, size): 119 | while self.parts_len < size: 120 | part = await self.queue.get() 121 | self.parts.append(part) 122 | self.parts_len += len(part) 123 | self.parts_len -= size 124 | whole = b''.join(self.parts) 125 | self.parts = [whole[size:]] 126 | return whole[:size] 127 | 128 | 129 | class BinaryFramer(object): 130 | """A framer for binary messaging protocols.""" 131 | 132 | def __init__(self): 133 | self.byte_queue = ByteQueue() 134 | self.message_queue = Queue() 135 | self.received_bytes = self.byte_queue.put_nowait 136 | 137 | def frame(self, message): 138 | command, payload = message 139 | return b''.join(( 140 | self._build_header(command, payload), 141 | payload 142 | )) 143 | 144 | async def receive_message(self): 145 | command, payload_len, checksum = await self._receive_header() 146 | payload = await self.byte_queue.receive(payload_len) 147 | payload_checksum = self._checksum(payload) 148 | if payload_checksum != checksum: 149 | raise BadChecksumError(payload_checksum, checksum) 150 | return command, payload 151 | 152 | def _checksum(self, payload): 153 | raise NotImplementedError 154 | 155 | def _build_header(self, command, payload): 156 | raise NotImplementedError 157 | 158 | async def _receive_header(self): 159 | raise NotImplementedError 160 | 161 | 162 | # Helpers 163 | struct_le_I = Struct('<I') 164 | pack_le_uint32 = struct_le_I.pack 165 | 166 | 167 | def sha256(x): 168 | """Simple wrapper of hashlib sha256.""" 169 | return _sha256(x).digest() 170 | 171 | 172 | def double_sha256(x): 173 | """SHA-256 of SHA-256, as used extensively in bitcoin.""" 174 | return sha256(sha256(x)) 175 | 176 | 177 | class BadChecksumError(Exception): 178 | pass 179 | 180 | 181 | class BadMagicError(Exception): 182 | pass 183 | 184 | 185 | class OversizedPayloadError(Exception): 186 | pass 187 | 188 | 189 | class BitcoinFramer(BinaryFramer): 190 | """Provides a framer of binary message payloads in the style of the 191 | Bitcoin network protocol. 192 | 193 | Each binary message has the following elements, in order: 194 | 195 | Magic - to confirm network (currently unused for stream sync) 196 | Command - padded command 197 | Length - payload length in bytes 198 | Checksum - checksum of the payload 199 | Payload - binary payload 200 | 201 | Call frame(command, payload) to get a framed message. 202 | Pass incoming network bytes to received_bytes(). 203 | Wait on receive_message() to get incoming (command, payload) pairs. 204 | """ 205 | 206 | def __init__(self, magic, max_block_size): 207 | def pad_command(command): 208 | fill = 12 - len(command) 209 | if fill < 0: 210 | raise ValueError(f'command {command} too long') 211 | return command + bytes(fill) 212 | 213 | super().__init__() 214 | self._magic = magic 215 | self._max_block_size = max_block_size 216 | self._pad_command = pad_command 217 | self._unpack = Struct(f'<4s12sI4s').unpack 218 | 219 | def _checksum(self, payload): 220 | return double_sha256(payload)[:4] 221 | 222 | def _build_header(self, command, payload): 223 | return b''.join(( 224 | self._magic, 225 | self._pad_command(command), 226 | pack_le_uint32(len(payload)), 227 | self._checksum(payload) 228 | )) 229 | 230 | async def _receive_header(self): 231 | header = await self.byte_queue.receive(24) 232 | magic, command, payload_len, checksum = self._unpack(header) 233 | if magic != self._magic: 234 | raise BadMagicError(magic, self._magic) 235 | command = command.rstrip(b'\0') 236 | if payload_len > 1024 * 1024: 237 | if command != b'block' or payload_len > self._max_block_size: 238 | raise OversizedPayloadError(command, payload_len) 239 | return command, payload_len, checksum 240 | -------------------------------------------------------------------------------- /torba/rpc/util.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2018, Neil Booth 2 | # 3 | # All rights reserved. 4 | # 5 | # The MIT License (MIT) 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining 8 | # a copy of this software and associated documentation files (the 9 | # "Software"), to deal in the Software without restriction, including 10 | # without limitation the rights to use, copy, modify, merge, publish, 11 | # distribute, sublicense, and/or sell copies of the Software, and to 12 | # permit persons to whom the Software is furnished to do so, subject to 13 | # the following conditions: 14 | # 15 | # The above copyright notice and this permission notice shall be 16 | # included in all copies or substantial portions of the Software. 17 | # 18 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 19 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 20 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 21 | # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 22 | # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 23 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 24 | # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 25 | 26 | __all__ = () 27 | 28 | 29 | import asyncio 30 | from collections import namedtuple 31 | import inspect 32 | 33 | # other_params: None means cannot be called with keyword arguments only 34 | # any means any name is good 35 | SignatureInfo = namedtuple('SignatureInfo', 'min_args max_args ' 36 | 'required_names other_names') 37 | 38 | 39 | def signature_info(func): 40 | params = inspect.signature(func).parameters 41 | min_args = max_args = 0 42 | required_names = [] 43 | other_names = [] 44 | no_names = False 45 | for p in params.values(): 46 | if p.kind == p.POSITIONAL_OR_KEYWORD: 47 | max_args += 1 48 | if p.default is p.empty: 49 | min_args += 1 50 | required_names.append(p.name) 51 | else: 52 | other_names.append(p.name) 53 | elif p.kind == p.KEYWORD_ONLY: 54 | other_names.append(p.name) 55 | elif p.kind == p.VAR_POSITIONAL: 56 | max_args = None 57 | elif p.kind == p.VAR_KEYWORD: 58 | other_names = any 59 | elif p.kind == p.POSITIONAL_ONLY: 60 | max_args += 1 61 | if p.default is p.empty: 62 | min_args += 1 63 | no_names = True 64 | 65 | if no_names: 66 | other_names = None 67 | 68 | return SignatureInfo(min_args, max_args, required_names, other_names) 69 | 70 | 71 | class Concurrency(object): 72 | 73 | def __init__(self, max_concurrent): 74 | self._require_non_negative(max_concurrent) 75 | self._max_concurrent = max_concurrent 76 | self.semaphore = asyncio.Semaphore(max_concurrent) 77 | 78 | def _require_non_negative(self, value): 79 | if not isinstance(value, int) or value < 0: 80 | raise RuntimeError('concurrency must be a natural number') 81 | 82 | @property 83 | def max_concurrent(self): 84 | return self._max_concurrent 85 | 86 | async def set_max_concurrent(self, value): 87 | self._require_non_negative(value) 88 | diff = value - self._max_concurrent 89 | self._max_concurrent = value 90 | if diff >= 0: 91 | for _ in range(diff): 92 | self.semaphore.release() 93 | else: 94 | for _ in range(-diff): 95 | await self.semaphore.acquire() 96 | -------------------------------------------------------------------------------- /torba/server/__init__.py: -------------------------------------------------------------------------------- 1 | from .server import Server 2 | -------------------------------------------------------------------------------- /torba/server/cli.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import traceback 3 | import argparse 4 | import importlib 5 | from torba.server.env import Env 6 | from torba.server.server import Server 7 | 8 | 9 | def get_argument_parser(): 10 | parser = argparse.ArgumentParser( 11 | prog="torba-server" 12 | ) 13 | parser.add_argument("spvserver", type=str, help="Python class path to SPV server implementation.") 14 | return parser 15 | 16 | 17 | def get_coin_class(spvserver): 18 | spvserver_path, coin_class_name = spvserver.rsplit('.', 1) 19 | spvserver_module = importlib.import_module(spvserver_path) 20 | return getattr(spvserver_module, coin_class_name) 21 | 22 | 23 | def main(): 24 | parser = get_argument_parser() 25 | args = parser.parse_args() 26 | coin_class = get_coin_class(args.spvserver) 27 | logging.basicConfig(level=logging.INFO) 28 | logging.info('torba.server starting') 29 | try: 30 | server = Server(Env(coin_class)) 31 | server.run() 32 | except Exception: 33 | traceback.print_exc() 34 | logging.critical('torba.server terminated abnormally') 35 | else: 36 | logging.info('torba.server terminated normally') 37 | 38 | 39 | if __name__ == "__main__": 40 | main() 41 | -------------------------------------------------------------------------------- /torba/server/enum.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016, Neil Booth 2 | # 3 | # All rights reserved. 4 | # 5 | # See the file "LICENCE" for information about the copyright 6 | # and warranty status of this software. 7 | 8 | """An enum-like type with reverse lookup. 9 | 10 | Source: Python Cookbook, http://code.activestate.com/recipes/67107/ 11 | """ 12 | 13 | 14 | class EnumError(Exception): 15 | pass 16 | 17 | 18 | class Enumeration: 19 | 20 | def __init__(self, name, enumList): 21 | self.__doc__ = name 22 | 23 | lookup = {} 24 | reverseLookup = {} 25 | i = 0 26 | uniqueNames = set() 27 | uniqueValues = set() 28 | for x in enumList: 29 | if isinstance(x, tuple): 30 | x, i = x 31 | if not isinstance(x, str): 32 | raise EnumError("enum name {} not a string".format(x)) 33 | if not isinstance(i, int): 34 | raise EnumError("enum value {} not an integer".format(i)) 35 | if x in uniqueNames: 36 | raise EnumError("enum name {} not unique".format(x)) 37 | if i in uniqueValues: 38 | raise EnumError("enum value {} not unique".format(x)) 39 | uniqueNames.add(x) 40 | uniqueValues.add(i) 41 | lookup[x] = i 42 | reverseLookup[i] = x 43 | i = i + 1 44 | self.lookup = lookup 45 | self.reverseLookup = reverseLookup 46 | 47 | def __getattr__(self, attr): 48 | result = self.lookup.get(attr) 49 | if result is None: 50 | raise AttributeError('enumeration has no member {}'.format(attr)) 51 | return result 52 | 53 | def whatis(self, value): 54 | return self.reverseLookup[value] 55 | -------------------------------------------------------------------------------- /torba/server/hash.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016-2017, Neil Booth 2 | # 3 | # All rights reserved. 4 | # 5 | # The MIT License (MIT) 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining 8 | # a copy of this software and associated documentation files (the 9 | # "Software"), to deal in the Software without restriction, including 10 | # without limitation the rights to use, copy, modify, merge, publish, 11 | # distribute, sublicense, and/or sell copies of the Software, and to 12 | # permit persons to whom the Software is furnished to do so, subject to 13 | # the following conditions: 14 | # 15 | # The above copyright notice and this permission notice shall be 16 | # included in all copies or substantial portions of the Software. 17 | # 18 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 19 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 20 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 21 | # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 22 | # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 23 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 24 | # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 25 | 26 | """Cryptograph hash functions and related classes.""" 27 | 28 | 29 | import hashlib 30 | import hmac 31 | 32 | from torba.server.util import bytes_to_int, int_to_bytes, hex_to_bytes 33 | 34 | _sha256 = hashlib.sha256 35 | _sha512 = hashlib.sha512 36 | _new_hash = hashlib.new 37 | _new_hmac = hmac.new 38 | HASHX_LEN = 11 39 | 40 | 41 | def sha256(x): 42 | """Simple wrapper of hashlib sha256.""" 43 | return _sha256(x).digest() 44 | 45 | 46 | def ripemd160(x): 47 | """Simple wrapper of hashlib ripemd160.""" 48 | h = _new_hash('ripemd160') 49 | h.update(x) 50 | return h.digest() 51 | 52 | 53 | def double_sha256(x): 54 | """SHA-256 of SHA-256, as used extensively in bitcoin.""" 55 | return sha256(sha256(x)) 56 | 57 | 58 | def hmac_sha512(key, msg): 59 | """Use SHA-512 to provide an HMAC.""" 60 | return _new_hmac(key, msg, _sha512).digest() 61 | 62 | 63 | def hash160(x): 64 | """RIPEMD-160 of SHA-256. 65 | 66 | Used to make bitcoin addresses from pubkeys.""" 67 | return ripemd160(sha256(x)) 68 | 69 | 70 | def hash_to_hex_str(x): 71 | """Convert a big-endian binary hash to displayed hex string. 72 | 73 | Display form of a binary hash is reversed and converted to hex. 74 | """ 75 | return bytes(reversed(x)).hex() 76 | 77 | 78 | def hex_str_to_hash(x): 79 | """Convert a displayed hex string to a binary hash.""" 80 | return bytes(reversed(hex_to_bytes(x))) 81 | 82 | 83 | class Base58Error(Exception): 84 | """Exception used for Base58 errors.""" 85 | 86 | 87 | class Base58: 88 | """Class providing base 58 functionality.""" 89 | 90 | chars = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' 91 | assert len(chars) == 58 92 | cmap = {c: n for n, c in enumerate(chars)} 93 | 94 | @staticmethod 95 | def char_value(c): 96 | val = Base58.cmap.get(c) 97 | if val is None: 98 | raise Base58Error('invalid base 58 character "{}"'.format(c)) 99 | return val 100 | 101 | @staticmethod 102 | def decode(txt): 103 | """Decodes txt into a big-endian bytearray.""" 104 | if not isinstance(txt, str): 105 | raise TypeError('a string is required') 106 | 107 | if not txt: 108 | raise Base58Error('string cannot be empty') 109 | 110 | value = 0 111 | for c in txt: 112 | value = value * 58 + Base58.char_value(c) 113 | 114 | result = int_to_bytes(value) 115 | 116 | # Prepend leading zero bytes if necessary 117 | count = 0 118 | for c in txt: 119 | if c != '1': 120 | break 121 | count += 1 122 | if count: 123 | result = bytes(count) + result 124 | 125 | return result 126 | 127 | @staticmethod 128 | def encode(be_bytes): 129 | """Converts a big-endian bytearray into a base58 string.""" 130 | value = bytes_to_int(be_bytes) 131 | 132 | txt = '' 133 | while value: 134 | value, mod = divmod(value, 58) 135 | txt += Base58.chars[mod] 136 | 137 | for byte in be_bytes: 138 | if byte != 0: 139 | break 140 | txt += '1' 141 | 142 | return txt[::-1] 143 | 144 | @staticmethod 145 | def decode_check(txt, *, hash_fn=double_sha256): 146 | """Decodes a Base58Check-encoded string to a payload. The version 147 | prefixes it.""" 148 | be_bytes = Base58.decode(txt) 149 | result, check = be_bytes[:-4], be_bytes[-4:] 150 | if check != hash_fn(result)[:4]: 151 | raise Base58Error('invalid base 58 checksum for {}'.format(txt)) 152 | return result 153 | 154 | @staticmethod 155 | def encode_check(payload, *, hash_fn=double_sha256): 156 | """Encodes a payload bytearray (which includes the version byte(s)) 157 | into a Base58Check string.""" 158 | be_bytes = payload + hash_fn(payload)[:4] 159 | return Base58.encode(be_bytes) 160 | -------------------------------------------------------------------------------- /torba/server/script.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016-2017, Neil Booth 2 | # 3 | # All rights reserved. 4 | # 5 | # The MIT License (MIT) 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining 8 | # a copy of this software and associated documentation files (the 9 | # "Software"), to deal in the Software without restriction, including 10 | # without limitation the rights to use, copy, modify, merge, publish, 11 | # distribute, sublicense, and/or sell copies of the Software, and to 12 | # permit persons to whom the Software is furnished to do so, subject to 13 | # the following conditions: 14 | # 15 | # The above copyright notice and this permission notice shall be 16 | # included in all copies or substantial portions of the Software. 17 | # 18 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 19 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 20 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 21 | # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 22 | # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 23 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 24 | # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 25 | # and warranty status of this software. 26 | 27 | """Script-related classes and functions.""" 28 | 29 | 30 | import struct 31 | from collections import namedtuple 32 | 33 | from torba.server.enum import Enumeration 34 | from torba.server.hash import hash160 35 | from torba.server.util import unpack_le_uint16_from, unpack_le_uint32_from, \ 36 | pack_le_uint16, pack_le_uint32 37 | 38 | 39 | class ScriptError(Exception): 40 | """Exception used for script errors.""" 41 | 42 | 43 | OpCodes = Enumeration("Opcodes", [ 44 | ("OP_0", 0), ("OP_PUSHDATA1", 76), 45 | "OP_PUSHDATA2", "OP_PUSHDATA4", "OP_1NEGATE", 46 | "OP_RESERVED", 47 | "OP_1", "OP_2", "OP_3", "OP_4", "OP_5", "OP_6", "OP_7", "OP_8", 48 | "OP_9", "OP_10", "OP_11", "OP_12", "OP_13", "OP_14", "OP_15", "OP_16", 49 | "OP_NOP", "OP_VER", "OP_IF", "OP_NOTIF", "OP_VERIF", "OP_VERNOTIF", 50 | "OP_ELSE", "OP_ENDIF", "OP_VERIFY", "OP_RETURN", 51 | "OP_TOALTSTACK", "OP_FROMALTSTACK", "OP_2DROP", "OP_2DUP", "OP_3DUP", 52 | "OP_2OVER", "OP_2ROT", "OP_2SWAP", "OP_IFDUP", "OP_DEPTH", "OP_DROP", 53 | "OP_DUP", "OP_NIP", "OP_OVER", "OP_PICK", "OP_ROLL", "OP_ROT", 54 | "OP_SWAP", "OP_TUCK", 55 | "OP_CAT", "OP_SUBSTR", "OP_LEFT", "OP_RIGHT", "OP_SIZE", 56 | "OP_INVERT", "OP_AND", "OP_OR", "OP_XOR", "OP_EQUAL", "OP_EQUALVERIFY", 57 | "OP_RESERVED1", "OP_RESERVED2", 58 | "OP_1ADD", "OP_1SUB", "OP_2MUL", "OP_2DIV", "OP_NEGATE", "OP_ABS", 59 | "OP_NOT", "OP_0NOTEQUAL", "OP_ADD", "OP_SUB", "OP_MUL", "OP_DIV", "OP_MOD", 60 | "OP_LSHIFT", "OP_RSHIFT", "OP_BOOLAND", "OP_BOOLOR", "OP_NUMEQUAL", 61 | "OP_NUMEQUALVERIFY", "OP_NUMNOTEQUAL", "OP_LESSTHAN", "OP_GREATERTHAN", 62 | "OP_LESSTHANOREQUAL", "OP_GREATERTHANOREQUAL", "OP_MIN", "OP_MAX", 63 | "OP_WITHIN", 64 | "OP_RIPEMD160", "OP_SHA1", "OP_SHA256", "OP_HASH160", "OP_HASH256", 65 | "OP_CODESEPARATOR", "OP_CHECKSIG", "OP_CHECKSIGVERIFY", "OP_CHECKMULTISIG", 66 | "OP_CHECKMULTISIGVERIFY", 67 | "OP_NOP1", 68 | "OP_CHECKLOCKTIMEVERIFY", "OP_CHECKSEQUENCEVERIFY" 69 | ]) 70 | 71 | 72 | # Paranoia to make it hard to create bad scripts 73 | assert OpCodes.OP_DUP == 0x76 74 | assert OpCodes.OP_HASH160 == 0xa9 75 | assert OpCodes.OP_EQUAL == 0x87 76 | assert OpCodes.OP_EQUALVERIFY == 0x88 77 | assert OpCodes.OP_CHECKSIG == 0xac 78 | assert OpCodes.OP_CHECKMULTISIG == 0xae 79 | 80 | 81 | def _match_ops(ops, pattern): 82 | if len(ops) != len(pattern): 83 | return False 84 | for op, pop in zip(ops, pattern): 85 | if pop != op: 86 | # -1 means 'data push', whose op is an (op, data) tuple 87 | if pop == -1 and isinstance(op, tuple): 88 | continue 89 | return False 90 | 91 | return True 92 | 93 | 94 | class ScriptPubKey: 95 | """A class for handling a tx output script that gives conditions 96 | necessary for spending. 97 | """ 98 | 99 | TO_ADDRESS_OPS = [OpCodes.OP_DUP, OpCodes.OP_HASH160, -1, 100 | OpCodes.OP_EQUALVERIFY, OpCodes.OP_CHECKSIG] 101 | TO_P2SH_OPS = [OpCodes.OP_HASH160, -1, OpCodes.OP_EQUAL] 102 | TO_PUBKEY_OPS = [-1, OpCodes.OP_CHECKSIG] 103 | 104 | PayToHandlers = namedtuple('PayToHandlers', 'address script_hash pubkey ' 105 | 'unspendable strange') 106 | 107 | @classmethod 108 | def pay_to(cls, handlers, script): 109 | """Parse a script, invoke the appropriate handler and 110 | return the result. 111 | 112 | One of the following handlers is invoked: 113 | handlers.address(hash160) 114 | handlers.script_hash(hash160) 115 | handlers.pubkey(pubkey) 116 | handlers.unspendable() 117 | handlers.strange(script) 118 | """ 119 | try: 120 | ops = Script.get_ops(script) 121 | except ScriptError: 122 | return handlers.unspendable() 123 | 124 | match = _match_ops 125 | 126 | if match(ops, cls.TO_ADDRESS_OPS): 127 | return handlers.address(ops[2][-1]) 128 | if match(ops, cls.TO_P2SH_OPS): 129 | return handlers.script_hash(ops[1][-1]) 130 | if match(ops, cls.TO_PUBKEY_OPS): 131 | return handlers.pubkey(ops[0][-1]) 132 | if ops and ops[0] == OpCodes.OP_RETURN: 133 | return handlers.unspendable() 134 | return handlers.strange(script) 135 | 136 | @classmethod 137 | def P2SH_script(cls, hash160): 138 | return (bytes([OpCodes.OP_HASH160]) 139 | + Script.push_data(hash160) 140 | + bytes([OpCodes.OP_EQUAL])) 141 | 142 | @classmethod 143 | def P2PKH_script(cls, hash160): 144 | return (bytes([OpCodes.OP_DUP, OpCodes.OP_HASH160]) 145 | + Script.push_data(hash160) 146 | + bytes([OpCodes.OP_EQUALVERIFY, OpCodes.OP_CHECKSIG])) 147 | 148 | @classmethod 149 | def validate_pubkey(cls, pubkey, req_compressed=False): 150 | if isinstance(pubkey, (bytes, bytearray)): 151 | if len(pubkey) == 33 and pubkey[0] in (2, 3): 152 | return # Compressed 153 | if len(pubkey) == 65 and pubkey[0] == 4: 154 | if not req_compressed: 155 | return 156 | raise PubKeyError('uncompressed pubkeys are invalid') 157 | raise PubKeyError('invalid pubkey {}'.format(pubkey)) 158 | 159 | @classmethod 160 | def pubkey_script(cls, pubkey): 161 | cls.validate_pubkey(pubkey) 162 | return Script.push_data(pubkey) + bytes([OpCodes.OP_CHECKSIG]) 163 | 164 | @classmethod 165 | def multisig_script(cls, m, pubkeys): 166 | """Returns the script for a pay-to-multisig transaction.""" 167 | n = len(pubkeys) 168 | if not 1 <= m <= n <= 15: 169 | raise ScriptError('{:d} of {:d} multisig script not possible' 170 | .format(m, n)) 171 | for pubkey in pubkeys: 172 | cls.validate_pubkey(pubkey, req_compressed=True) 173 | # See https://bitcoin.org/en/developer-guide 174 | # 2 of 3 is: OP_2 pubkey1 pubkey2 pubkey3 OP_3 OP_CHECKMULTISIG 175 | return (bytes([OP_1 + m - 1]) 176 | + b''.join(cls.push_data(pubkey) for pubkey in pubkeys) 177 | + bytes([OP_1 + n - 1, OP_CHECK_MULTISIG])) 178 | 179 | 180 | class Script: 181 | 182 | @classmethod 183 | def get_ops(cls, script): 184 | ops = [] 185 | 186 | # The unpacks or script[n] below throw on truncated scripts 187 | try: 188 | n = 0 189 | while n < len(script): 190 | op = script[n] 191 | n += 1 192 | 193 | if op <= OpCodes.OP_PUSHDATA4: 194 | # Raw bytes follow 195 | if op < OpCodes.OP_PUSHDATA1: 196 | dlen = op 197 | elif op == OpCodes.OP_PUSHDATA1: 198 | dlen = script[n] 199 | n += 1 200 | elif op == OpCodes.OP_PUSHDATA2: 201 | dlen, = unpack_le_uint16_from(script[n: n + 2]) 202 | n += 2 203 | else: 204 | dlen, = unpack_le_uint32_from(script[n: n + 4]) 205 | n += 4 206 | if n + dlen > len(script): 207 | raise IndexError 208 | op = (op, script[n:n + dlen]) 209 | n += dlen 210 | 211 | ops.append(op) 212 | except Exception: 213 | # Truncated script; e.g. tx_hash 214 | # ebc9fa1196a59e192352d76c0f6e73167046b9d37b8302b6bb6968dfd279b767 215 | raise ScriptError('truncated script') 216 | 217 | return ops 218 | 219 | @classmethod 220 | def push_data(cls, data): 221 | """Returns the opcodes to push the data on the stack.""" 222 | assert isinstance(data, (bytes, bytearray)) 223 | 224 | n = len(data) 225 | if n < OpCodes.OP_PUSHDATA1: 226 | return bytes([n]) + data 227 | if n < 256: 228 | return bytes([OpCodes.OP_PUSHDATA1, n]) + data 229 | if n < 65536: 230 | return bytes([OpCodes.OP_PUSHDATA2]) + pack_le_uint16(n) + data 231 | return bytes([OpCodes.OP_PUSHDATA4]) + pack_le_uint32(n) + data 232 | 233 | @classmethod 234 | def opcode_name(cls, opcode): 235 | if OpCodes.OP_0 < opcode < OpCodes.OP_PUSHDATA1: 236 | return 'OP_{:d}'.format(opcode) 237 | try: 238 | return OpCodes.whatis(opcode) 239 | except KeyError: 240 | return 'OP_UNKNOWN:{:d}'.format(opcode) 241 | 242 | @classmethod 243 | def dump(cls, script): 244 | opcodes, datas = cls.get_ops(script) 245 | for opcode, data in zip(opcodes, datas): 246 | name = cls.opcode_name(opcode) 247 | if data is None: 248 | print(name) 249 | else: 250 | print('{} {} ({:d} bytes)' 251 | .format(name, data.hex(), len(data))) 252 | -------------------------------------------------------------------------------- /torba/server/server.py: -------------------------------------------------------------------------------- 1 | import signal 2 | import logging 3 | import asyncio 4 | from concurrent.futures.thread import ThreadPoolExecutor 5 | 6 | import torba 7 | from torba.server.mempool import MemPool, MemPoolAPI 8 | from torba.server.session import SessionManager 9 | 10 | 11 | class Notifications: 12 | # hashX notifications come from two sources: new blocks and 13 | # mempool refreshes. 14 | # 15 | # A user with a pending transaction is notified after the block it 16 | # gets in is processed. Block processing can take an extended 17 | # time, and the prefetcher might poll the daemon after the mempool 18 | # code in any case. In such cases the transaction will not be in 19 | # the mempool after the mempool refresh. We want to avoid 20 | # notifying clients twice - for the mempool refresh and when the 21 | # block is done. This object handles that logic by deferring 22 | # notifications appropriately. 23 | 24 | def __init__(self): 25 | self._touched_mp = {} 26 | self._touched_bp = {} 27 | self._highest_block = -1 28 | 29 | async def _maybe_notify(self): 30 | tmp, tbp = self._touched_mp, self._touched_bp 31 | common = set(tmp).intersection(tbp) 32 | if common: 33 | height = max(common) 34 | elif tmp and max(tmp) == self._highest_block: 35 | height = self._highest_block 36 | else: 37 | # Either we are processing a block and waiting for it to 38 | # come in, or we have not yet had a mempool update for the 39 | # new block height 40 | return 41 | touched = tmp.pop(height) 42 | for old in [h for h in tmp if h <= height]: 43 | del tmp[old] 44 | for old in [h for h in tbp if h <= height]: 45 | touched.update(tbp.pop(old)) 46 | await self.notify(height, touched) 47 | 48 | async def notify(self, height, touched): 49 | pass 50 | 51 | async def start(self, height, notify_func): 52 | self._highest_block = height 53 | self.notify = notify_func 54 | await self.notify(height, set()) 55 | 56 | async def on_mempool(self, touched, height): 57 | self._touched_mp[height] = touched 58 | await self._maybe_notify() 59 | 60 | async def on_block(self, touched, height): 61 | self._touched_bp[height] = touched 62 | self._highest_block = height 63 | await self._maybe_notify() 64 | 65 | 66 | class Server: 67 | 68 | def __init__(self, env): 69 | self.env = env 70 | self.log = logging.getLogger(__name__).getChild(self.__class__.__name__) 71 | self.shutdown_event = asyncio.Event() 72 | self.cancellable_tasks = [] 73 | 74 | self.notifications = notifications = Notifications() 75 | self.daemon = daemon = env.coin.DAEMON(env.coin, env.daemon_url) 76 | self.db = db = env.coin.DB(env) 77 | self.bp = bp = env.coin.BLOCK_PROCESSOR(env, db, daemon, notifications) 78 | 79 | # Set notifications up to implement the MemPoolAPI 80 | notifications.height = daemon.height 81 | notifications.cached_height = daemon.cached_height 82 | notifications.mempool_hashes = daemon.mempool_hashes 83 | notifications.raw_transactions = daemon.getrawtransactions 84 | notifications.lookup_utxos = db.lookup_utxos 85 | MemPoolAPI.register(Notifications) 86 | self.mempool = mempool = MemPool(env.coin, notifications) 87 | 88 | self.session_mgr = SessionManager( 89 | env, db, bp, daemon, mempool, self.shutdown_event 90 | ) 91 | 92 | async def start(self): 93 | env = self.env 94 | min_str, max_str = env.coin.SESSIONCLS.protocol_min_max_strings() 95 | self.log.info(f'software version: {torba.__version__}') 96 | self.log.info(f'supported protocol versions: {min_str}-{max_str}') 97 | self.log.info(f'event loop policy: {env.loop_policy}') 98 | self.log.info(f'reorg limit is {env.reorg_limit:,d} blocks') 99 | 100 | await self.daemon.height() 101 | 102 | def _start_cancellable(run, *args): 103 | _flag = asyncio.Event() 104 | self.cancellable_tasks.append(asyncio.ensure_future(run(*args, _flag))) 105 | return _flag.wait() 106 | 107 | await _start_cancellable(self.bp.fetch_and_process_blocks) 108 | await self.db.populate_header_merkle_cache() 109 | await _start_cancellable(self.mempool.keep_synchronized) 110 | await _start_cancellable(self.session_mgr.serve, self.notifications) 111 | 112 | async def stop(self): 113 | for task in reversed(self.cancellable_tasks): 114 | task.cancel() 115 | await asyncio.wait(self.cancellable_tasks) 116 | self.shutdown_event.set() 117 | 118 | def run(self): 119 | loop = asyncio.get_event_loop() 120 | executor = ThreadPoolExecutor(1) 121 | loop.set_default_executor(executor) 122 | 123 | def __exit(): 124 | raise SystemExit() 125 | try: 126 | loop.add_signal_handler(signal.SIGINT, __exit) 127 | loop.add_signal_handler(signal.SIGTERM, __exit) 128 | loop.run_until_complete(self.start()) 129 | loop.run_until_complete(self.shutdown_event.wait()) 130 | except (SystemExit, KeyboardInterrupt): 131 | pass 132 | finally: 133 | loop.run_until_complete(self.stop()) 134 | executor.shutdown(True) 135 | -------------------------------------------------------------------------------- /torba/server/storage.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2016-2017, the ElectrumX authors 2 | # 3 | # All rights reserved. 4 | # 5 | # See the file "LICENCE" for information about the copyright 6 | # and warranty status of this software. 7 | 8 | """Backend database abstraction.""" 9 | 10 | import os 11 | from functools import partial 12 | 13 | from torba.server import util 14 | 15 | 16 | def db_class(name): 17 | """Returns a DB engine class.""" 18 | for db_class in util.subclasses(Storage): 19 | if db_class.__name__.lower() == name.lower(): 20 | db_class.import_module() 21 | return db_class 22 | raise RuntimeError('unrecognised DB engine "{}"'.format(name)) 23 | 24 | 25 | class Storage: 26 | """Abstract base class of the DB backend abstraction.""" 27 | 28 | def __init__(self, name, for_sync): 29 | self.is_new = not os.path.exists(name) 30 | self.for_sync = for_sync or self.is_new 31 | self.open(name, create=self.is_new) 32 | 33 | @classmethod 34 | def import_module(cls): 35 | """Import the DB engine module.""" 36 | raise NotImplementedError 37 | 38 | def open(self, name, create): 39 | """Open an existing database or create a new one.""" 40 | raise NotImplementedError 41 | 42 | def close(self): 43 | """Close an existing database.""" 44 | raise NotImplementedError 45 | 46 | def get(self, key): 47 | raise NotImplementedError 48 | 49 | def put(self, key, value): 50 | raise NotImplementedError 51 | 52 | def write_batch(self): 53 | """Return a context manager that provides `put` and `delete`. 54 | 55 | Changes should only be committed when the context manager 56 | closes without an exception. 57 | """ 58 | raise NotImplementedError 59 | 60 | def iterator(self, prefix=b'', reverse=False): 61 | """Return an iterator that yields (key, value) pairs from the 62 | database sorted by key. 63 | 64 | If `prefix` is set, only keys starting with `prefix` will be 65 | included. If `reverse` is True the items are returned in 66 | reverse order. 67 | """ 68 | raise NotImplementedError 69 | 70 | 71 | class LevelDB(Storage): 72 | """LevelDB database engine.""" 73 | 74 | @classmethod 75 | def import_module(cls): 76 | import plyvel 77 | cls.module = plyvel 78 | 79 | def open(self, name, create): 80 | mof = 512 if self.for_sync else 128 81 | # Use snappy compression (the default) 82 | self.db = self.module.DB(name, create_if_missing=create, 83 | max_open_files=mof) 84 | self.close = self.db.close 85 | self.get = self.db.get 86 | self.put = self.db.put 87 | self.iterator = self.db.iterator 88 | self.write_batch = partial(self.db.write_batch, transaction=True, 89 | sync=True) 90 | 91 | 92 | class RocksDB(Storage): 93 | """RocksDB database engine.""" 94 | 95 | @classmethod 96 | def import_module(cls): 97 | import rocksdb 98 | cls.module = rocksdb 99 | 100 | def open(self, name, create): 101 | mof = 512 if self.for_sync else 128 102 | # Use snappy compression (the default) 103 | options = self.module.Options(create_if_missing=create, 104 | use_fsync=True, 105 | target_file_size_base=33554432, 106 | max_open_files=mof) 107 | self.db = self.module.DB(name, options) 108 | self.get = self.db.get 109 | self.put = self.db.put 110 | 111 | def close(self): 112 | # PyRocksDB doesn't provide a close method; hopefully this is enough 113 | self.db = self.get = self.put = None 114 | import gc 115 | gc.collect() 116 | 117 | def write_batch(self): 118 | return RocksDBWriteBatch(self.db) 119 | 120 | def iterator(self, prefix=b'', reverse=False): 121 | return RocksDBIterator(self.db, prefix, reverse) 122 | 123 | 124 | class RocksDBWriteBatch: 125 | """A write batch for RocksDB.""" 126 | 127 | def __init__(self, db): 128 | self.batch = RocksDB.module.WriteBatch() 129 | self.db = db 130 | 131 | def __enter__(self): 132 | return self.batch 133 | 134 | def __exit__(self, exc_type, exc_val, exc_tb): 135 | if not exc_val: 136 | self.db.write(self.batch) 137 | 138 | 139 | class RocksDBIterator: 140 | """An iterator for RocksDB.""" 141 | 142 | def __init__(self, db, prefix, reverse): 143 | self.prefix = prefix 144 | if reverse: 145 | self.iterator = reversed(db.iteritems()) 146 | nxt_prefix = util.increment_byte_string(prefix) 147 | if nxt_prefix: 148 | self.iterator.seek(nxt_prefix) 149 | try: 150 | next(self.iterator) 151 | except StopIteration: 152 | self.iterator.seek(nxt_prefix) 153 | else: 154 | self.iterator.seek_to_last() 155 | else: 156 | self.iterator = db.iteritems() 157 | self.iterator.seek(prefix) 158 | 159 | def __iter__(self): 160 | return self 161 | 162 | def __next__(self): 163 | k, v = next(self.iterator) 164 | if not k.startswith(self.prefix): 165 | raise StopIteration 166 | return k, v 167 | -------------------------------------------------------------------------------- /torba/server/text.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from torba.server import util 4 | 5 | 6 | def sessions_lines(data): 7 | """A generator returning lines for a list of sessions. 8 | 9 | data is the return value of rpc_sessions().""" 10 | fmt = ('{:<6} {:<5} {:>17} {:>5} {:>5} {:>5} ' 11 | '{:>7} {:>7} {:>7} {:>7} {:>7} {:>9} {:>21}') 12 | yield fmt.format('ID', 'Flags', 'Client', 'Proto', 13 | 'Reqs', 'Txs', 'Subs', 14 | 'Recv', 'Recv KB', 'Sent', 'Sent KB', 'Time', 'Peer') 15 | for (id_, flags, peer, client, proto, reqs, txs_sent, subs, 16 | recv_count, recv_size, send_count, send_size, time) in data: 17 | yield fmt.format(id_, flags, client, proto, 18 | '{:,d}'.format(reqs), 19 | '{:,d}'.format(txs_sent), 20 | '{:,d}'.format(subs), 21 | '{:,d}'.format(recv_count), 22 | '{:,d}'.format(recv_size // 1024), 23 | '{:,d}'.format(send_count), 24 | '{:,d}'.format(send_size // 1024), 25 | util.formatted_time(time, sep=''), peer) 26 | 27 | 28 | def groups_lines(data): 29 | """A generator returning lines for a list of groups. 30 | 31 | data is the return value of rpc_groups().""" 32 | 33 | fmt = ('{:<6} {:>9} {:>9} {:>6} {:>6} {:>8}' 34 | '{:>7} {:>9} {:>7} {:>9}') 35 | yield fmt.format('ID', 'Sessions', 'Bwidth KB', 'Reqs', 'Txs', 'Subs', 36 | 'Recv', 'Recv KB', 'Sent', 'Sent KB') 37 | for (id_, session_count, bandwidth, reqs, txs_sent, subs, 38 | recv_count, recv_size, send_count, send_size) in data: 39 | yield fmt.format(id_, 40 | '{:,d}'.format(session_count), 41 | '{:,d}'.format(bandwidth // 1024), 42 | '{:,d}'.format(reqs), 43 | '{:,d}'.format(txs_sent), 44 | '{:,d}'.format(subs), 45 | '{:,d}'.format(recv_count), 46 | '{:,d}'.format(recv_size // 1024), 47 | '{:,d}'.format(send_count), 48 | '{:,d}'.format(send_size // 1024)) 49 | 50 | 51 | def peers_lines(data): 52 | """A generator returning lines for a list of peers. 53 | 54 | data is the return value of rpc_peers().""" 55 | def time_fmt(t): 56 | if not t: 57 | return 'Never' 58 | return util.formatted_time(now - t) 59 | 60 | now = time.time() 61 | fmt = ('{:<30} {:<6} {:>5} {:>5} {:<17} {:>4} ' 62 | '{:>4} {:>8} {:>11} {:>11} {:>5} {:>20} {:<15}') 63 | yield fmt.format('Host', 'Status', 'TCP', 'SSL', 'Server', 'Min', 64 | 'Max', 'Pruning', 'Last Good', 'Last Try', 65 | 'Tries', 'Source', 'IP Address') 66 | for item in data: 67 | features = item['features'] 68 | hostname = item['host'] 69 | host = features['hosts'][hostname] 70 | yield fmt.format(hostname[:30], 71 | item['status'], 72 | host.get('tcp_port') or '', 73 | host.get('ssl_port') or '', 74 | features['server_version'] or 'unknown', 75 | features['protocol_min'], 76 | features['protocol_max'], 77 | features['pruning'] or '', 78 | time_fmt(item['last_good']), 79 | time_fmt(item['last_try']), 80 | item['try_count'], 81 | item['source'][:20], 82 | item['ip_addr'] or '') 83 | -------------------------------------------------------------------------------- /torba/stream.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | 4 | class BroadcastSubscription: 5 | 6 | def __init__(self, controller, on_data, on_error, on_done): 7 | self._controller = controller 8 | self._previous = self._next = None 9 | self._on_data = on_data 10 | self._on_error = on_error 11 | self._on_done = on_done 12 | self.is_paused = False 13 | self.is_canceled = False 14 | self.is_closed = False 15 | 16 | def pause(self): 17 | self.is_paused = True 18 | 19 | def resume(self): 20 | self.is_paused = False 21 | 22 | def cancel(self): 23 | self._controller._cancel(self) 24 | self.is_canceled = True 25 | 26 | @property 27 | def can_fire(self): 28 | return not any((self.is_paused, self.is_canceled, self.is_closed)) 29 | 30 | def _add(self, data): 31 | if self.can_fire and self._on_data is not None: 32 | return self._on_data(data) 33 | 34 | def _add_error(self, exception): 35 | if self.can_fire and self._on_error is not None: 36 | return self._on_error(exception) 37 | 38 | def _close(self): 39 | try: 40 | if self.can_fire and self._on_done is not None: 41 | return self._on_done() 42 | finally: 43 | self.is_closed = True 44 | 45 | 46 | class StreamController: 47 | 48 | def __init__(self): 49 | self.stream = Stream(self) 50 | self._first_subscription = None 51 | self._last_subscription = None 52 | 53 | @property 54 | def has_listener(self): 55 | return self._first_subscription is not None 56 | 57 | @property 58 | def _iterate_subscriptions(self): 59 | next_sub = self._first_subscription 60 | while next_sub is not None: 61 | subscription = next_sub 62 | next_sub = next_sub._next 63 | yield subscription 64 | 65 | def _notify_and_ensure_future(self, notify): 66 | tasks = [] 67 | for subscription in self._iterate_subscriptions: 68 | maybe_coroutine = notify(subscription) 69 | if asyncio.iscoroutine(maybe_coroutine): 70 | tasks.append(maybe_coroutine) 71 | if tasks: 72 | return asyncio.ensure_future(asyncio.wait(tasks)) 73 | else: 74 | f = asyncio.get_event_loop().create_future() 75 | f.set_result(None) 76 | return f 77 | 78 | def add(self, event): 79 | return self._notify_and_ensure_future( 80 | lambda subscription: subscription._add(event) 81 | ) 82 | 83 | def add_error(self, exception): 84 | return self._notify_and_ensure_future( 85 | lambda subscription: subscription._add_error(exception) 86 | ) 87 | 88 | def close(self): 89 | for subscription in self._iterate_subscriptions: 90 | subscription._close() 91 | 92 | def _cancel(self, subscription): 93 | previous = subscription._previous 94 | next_sub = subscription._next 95 | if previous is None: 96 | self._first_subscription = next_sub 97 | else: 98 | previous._next = next_sub 99 | if next_sub is None: 100 | self._last_subscription = previous 101 | else: 102 | next_sub._previous = previous 103 | subscription._next = subscription._previous = subscription 104 | 105 | def _listen(self, on_data, on_error, on_done): 106 | subscription = BroadcastSubscription(self, on_data, on_error, on_done) 107 | old_last = self._last_subscription 108 | self._last_subscription = subscription 109 | subscription._previous = old_last 110 | subscription._next = None 111 | if old_last is None: 112 | self._first_subscription = subscription 113 | else: 114 | old_last._next = subscription 115 | return subscription 116 | 117 | 118 | class Stream: 119 | 120 | def __init__(self, controller): 121 | self._controller = controller 122 | 123 | def listen(self, on_data, on_error=None, on_done=None): 124 | return self._controller._listen(on_data, on_error, on_done) 125 | 126 | def where(self, condition) -> asyncio.Future: 127 | future = asyncio.get_event_loop().create_future() 128 | 129 | def where_test(value): 130 | if condition(value): 131 | self._cancel_and_callback(subscription, future, value) 132 | 133 | subscription = self.listen( 134 | where_test, 135 | lambda exception: self._cancel_and_error(subscription, future, exception) 136 | ) 137 | 138 | return future 139 | 140 | @property 141 | def first(self): 142 | future = asyncio.get_event_loop().create_future() 143 | subscription = self.listen( 144 | lambda value: self._cancel_and_callback(subscription, future, value), 145 | lambda exception: self._cancel_and_error(subscription, future, exception) 146 | ) 147 | return future 148 | 149 | @staticmethod 150 | def _cancel_and_callback(subscription: BroadcastSubscription, future: asyncio.Future, value): 151 | subscription.cancel() 152 | future.set_result(value) 153 | 154 | @staticmethod 155 | def _cancel_and_error(subscription: BroadcastSubscription, future: asyncio.Future, exception): 156 | subscription.cancel() 157 | future.set_exception(exception) 158 | -------------------------------------------------------------------------------- /torba/tasks.py: -------------------------------------------------------------------------------- 1 | from asyncio import Event, get_event_loop 2 | 3 | 4 | class TaskGroup: 5 | 6 | def __init__(self, loop=None): 7 | self._loop = loop or get_event_loop() 8 | self._tasks = set() 9 | self.done = Event() 10 | 11 | def add(self, coro): 12 | task = self._loop.create_task(coro) 13 | self._tasks.add(task) 14 | self.done.clear() 15 | task.add_done_callback(self._remove) 16 | return task 17 | 18 | def _remove(self, task): 19 | self._tasks.remove(task) 20 | len(self._tasks) < 1 and self.done.set() 21 | 22 | def cancel(self): 23 | for task in self._tasks: 24 | task.cancel() 25 | -------------------------------------------------------------------------------- /torba/testcase.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import logging 3 | import functools 4 | import asyncio 5 | from asyncio.runners import _cancel_all_tasks # type: ignore 6 | import unittest 7 | from unittest.case import _Outcome 8 | from typing import Optional 9 | from torba.orchstr8 import Conductor 10 | from torba.orchstr8.node import BlockchainNode, WalletNode 11 | from torba.client.baseledger import BaseLedger 12 | from torba.client.baseaccount import BaseAccount 13 | from torba.client.basemanager import BaseWalletManager 14 | from torba.client.wallet import Wallet 15 | from torba.client.util import satoshis_to_coins 16 | 17 | 18 | class ColorHandler(logging.StreamHandler): 19 | 20 | level_color = { 21 | logging.DEBUG: "black", 22 | logging.INFO: "light_gray", 23 | logging.WARNING: "yellow", 24 | logging.ERROR: "red" 25 | } 26 | 27 | color_code = dict( 28 | black=30, 29 | red=31, 30 | green=32, 31 | yellow=33, 32 | blue=34, 33 | magenta=35, 34 | cyan=36, 35 | white=37, 36 | light_gray='0;37', 37 | dark_gray='1;30' 38 | ) 39 | 40 | def emit(self, record): 41 | try: 42 | msg = self.format(record) 43 | color_name = self.level_color.get(record.levelno, "black") 44 | color_code = self.color_code[color_name] 45 | stream = self.stream 46 | stream.write('\x1b[%sm%s\x1b[0m' % (color_code, msg)) 47 | stream.write(self.terminator) 48 | self.flush() 49 | except Exception: 50 | self.handleError(record) 51 | 52 | 53 | HANDLER = ColorHandler(sys.stdout) 54 | HANDLER.setFormatter( 55 | logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') 56 | ) 57 | logging.getLogger().addHandler(HANDLER) 58 | 59 | 60 | class AsyncioTestCase(unittest.TestCase): 61 | # Implementation inspired by discussion: 62 | # https://bugs.python.org/issue32972 63 | 64 | maxDiff = None 65 | 66 | async def asyncSetUp(self): # pylint: disable=C0103 67 | pass 68 | 69 | async def asyncTearDown(self): # pylint: disable=C0103 70 | pass 71 | 72 | def run(self, result=None): # pylint: disable=R0915 73 | orig_result = result 74 | if result is None: 75 | result = self.defaultTestResult() 76 | startTestRun = getattr(result, 'startTestRun', None) # pylint: disable=C0103 77 | if startTestRun is not None: 78 | startTestRun() 79 | 80 | result.startTest(self) 81 | 82 | testMethod = getattr(self, self._testMethodName) # pylint: disable=C0103 83 | if (getattr(self.__class__, "__unittest_skip__", False) or 84 | getattr(testMethod, "__unittest_skip__", False)): 85 | # If the class or method was skipped. 86 | try: 87 | skip_why = (getattr(self.__class__, '__unittest_skip_why__', '') 88 | or getattr(testMethod, '__unittest_skip_why__', '')) 89 | self._addSkip(result, self, skip_why) 90 | finally: 91 | result.stopTest(self) 92 | return 93 | expecting_failure_method = getattr(testMethod, 94 | "__unittest_expecting_failure__", False) 95 | expecting_failure_class = getattr(self, 96 | "__unittest_expecting_failure__", False) 97 | expecting_failure = expecting_failure_class or expecting_failure_method 98 | outcome = _Outcome(result) 99 | 100 | self.loop = asyncio.new_event_loop() # pylint: disable=W0201 101 | asyncio.set_event_loop(self.loop) 102 | self.loop.set_debug(True) 103 | 104 | try: 105 | self._outcome = outcome 106 | 107 | with outcome.testPartExecutor(self): 108 | self.setUp() 109 | self.loop.run_until_complete(self.asyncSetUp()) 110 | if outcome.success: 111 | outcome.expecting_failure = expecting_failure 112 | with outcome.testPartExecutor(self, isTest=True): 113 | maybe_coroutine = testMethod() 114 | if asyncio.iscoroutine(maybe_coroutine): 115 | self.loop.run_until_complete(maybe_coroutine) 116 | outcome.expecting_failure = False 117 | with outcome.testPartExecutor(self): 118 | self.loop.run_until_complete(self.asyncTearDown()) 119 | self.tearDown() 120 | 121 | self.doAsyncCleanups() 122 | 123 | try: 124 | _cancel_all_tasks(self.loop) 125 | self.loop.run_until_complete(self.loop.shutdown_asyncgens()) 126 | finally: 127 | asyncio.set_event_loop(None) 128 | self.loop.close() 129 | 130 | for test, reason in outcome.skipped: 131 | self._addSkip(result, test, reason) 132 | self._feedErrorsToResult(result, outcome.errors) 133 | if outcome.success: 134 | if expecting_failure: 135 | if outcome.expectedFailure: 136 | self._addExpectedFailure(result, outcome.expectedFailure) 137 | else: 138 | self._addUnexpectedSuccess(result) 139 | else: 140 | result.addSuccess(self) 141 | return result 142 | finally: 143 | result.stopTest(self) 144 | if orig_result is None: 145 | stopTestRun = getattr(result, 'stopTestRun', None) # pylint: disable=C0103 146 | if stopTestRun is not None: 147 | stopTestRun() # pylint: disable=E1102 148 | 149 | # explicitly break reference cycles: 150 | # outcome.errors -> frame -> outcome -> outcome.errors 151 | # outcome.expectedFailure -> frame -> outcome -> outcome.expectedFailure 152 | outcome.errors.clear() 153 | outcome.expectedFailure = None 154 | 155 | # clear the outcome, no more needed 156 | self._outcome = None 157 | 158 | def doAsyncCleanups(self): # pylint: disable=C0103 159 | outcome = self._outcome or _Outcome() 160 | while self._cleanups: 161 | function, args, kwargs = self._cleanups.pop() 162 | with outcome.testPartExecutor(self): 163 | maybe_coroutine = function(*args, **kwargs) 164 | if asyncio.iscoroutine(maybe_coroutine): 165 | self.loop.run_until_complete(maybe_coroutine) 166 | 167 | 168 | class AdvanceTimeTestCase(AsyncioTestCase): 169 | 170 | async def asyncSetUp(self): 171 | self._time = 0 # pylint: disable=W0201 172 | self.loop.time = functools.wraps(self.loop.time)(lambda: self._time) 173 | await super().asyncSetUp() 174 | 175 | async def advance(self, seconds): 176 | while self.loop._ready: 177 | await asyncio.sleep(0) 178 | self._time += seconds 179 | await asyncio.sleep(0) 180 | while self.loop._ready: 181 | await asyncio.sleep(0) 182 | 183 | 184 | class IntegrationTestCase(AsyncioTestCase): 185 | 186 | LEDGER = None 187 | MANAGER = None 188 | VERBOSITY = logging.WARN 189 | 190 | def __init__(self, *args, **kwargs): 191 | super().__init__(*args, **kwargs) 192 | self.conductor: Optional[Conductor] = None 193 | self.blockchain: Optional[BlockchainNode] = None 194 | self.wallet_node: Optional[WalletNode] = None 195 | self.manager: Optional[BaseWalletManager] = None 196 | self.ledger: Optional[BaseLedger] = None 197 | self.wallet: Optional[Wallet] = None 198 | self.account: Optional[BaseAccount] = None 199 | 200 | async def asyncSetUp(self): 201 | self.conductor = Conductor( 202 | ledger_module=self.LEDGER, manager_module=self.MANAGER, verbosity=self.VERBOSITY 203 | ) 204 | await self.conductor.start_blockchain() 205 | self.addCleanup(self.conductor.stop_blockchain) 206 | await self.conductor.start_spv() 207 | self.addCleanup(self.conductor.stop_spv) 208 | await self.conductor.start_wallet() 209 | self.addCleanup(self.conductor.stop_wallet) 210 | self.blockchain = self.conductor.blockchain_node 211 | self.wallet_node = self.conductor.wallet_node 212 | self.manager = self.wallet_node.manager 213 | self.ledger = self.wallet_node.ledger 214 | self.wallet = self.wallet_node.wallet 215 | self.account = self.wallet_node.wallet.default_account 216 | 217 | async def assertBalance(self, account, expected_balance: str): # pylint: disable=C0103 218 | balance = await account.get_balance() 219 | self.assertEqual(satoshis_to_coins(balance), expected_balance) 220 | 221 | def broadcast(self, tx): 222 | return self.ledger.broadcast(tx) 223 | 224 | async def on_header(self, height): 225 | if self.ledger.headers.height < height: 226 | await self.ledger.on_header.where( 227 | lambda e: e.height == height 228 | ) 229 | return True 230 | 231 | def on_transaction_id(self, txid, ledger=None): 232 | return (ledger or self.ledger).on_transaction.where( 233 | lambda e: e.tx.id == txid 234 | ) 235 | 236 | def on_transaction_address(self, tx, address): 237 | return self.ledger.on_transaction.where( 238 | lambda e: e.tx.id == tx.id and e.address == address 239 | ) 240 | -------------------------------------------------------------------------------- /torba/ui/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lbryio/torba/190304344c0ff68f8a24cf50272307a11bf7f62b/torba/ui/__init__.py -------------------------------------------------------------------------------- /torba/workbench/Makefile: -------------------------------------------------------------------------------- 1 | all: _blockchain_dock.py _output_dock.py 2 | _blockchain_dock.py: blockchain_dock.ui 3 | pyside2-uic -d blockchain_dock.ui -o _blockchain_dock.py 4 | _output_dock.py: output_dock.ui 5 | pyside2-uic -d output_dock.ui -o _output_dock.py 6 | -------------------------------------------------------------------------------- /torba/workbench/__init__.py: -------------------------------------------------------------------------------- 1 | from .application import main 2 | -------------------------------------------------------------------------------- /torba/workbench/_blockchain_dock.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Form implementation generated from reading ui file 'blockchain_dock.ui', 4 | # licensing of 'blockchain_dock.ui' applies. 5 | # 6 | # Created: Sun Jan 13 02:56:21 2019 7 | # by: pyside2-uic running on PySide2 5.12.0 8 | # 9 | # WARNING! All changes made in this file will be lost! 10 | 11 | from PySide2 import QtCore, QtGui, QtWidgets 12 | 13 | class Ui_BlockchainDock(object): 14 | def setupUi(self, BlockchainDock): 15 | BlockchainDock.setObjectName("BlockchainDock") 16 | BlockchainDock.resize(416, 167) 17 | BlockchainDock.setFloating(False) 18 | BlockchainDock.setFeatures(QtWidgets.QDockWidget.AllDockWidgetFeatures) 19 | self.dockWidgetContents = QtWidgets.QWidget() 20 | self.dockWidgetContents.setObjectName("dockWidgetContents") 21 | self.formLayout = QtWidgets.QFormLayout(self.dockWidgetContents) 22 | self.formLayout.setObjectName("formLayout") 23 | self.generate = QtWidgets.QPushButton(self.dockWidgetContents) 24 | self.generate.setObjectName("generate") 25 | self.formLayout.setWidget(0, QtWidgets.QFormLayout.LabelRole, self.generate) 26 | self.blocks = QtWidgets.QSpinBox(self.dockWidgetContents) 27 | self.blocks.setMinimum(1) 28 | self.blocks.setMaximum(9999) 29 | self.blocks.setProperty("value", 1) 30 | self.blocks.setObjectName("blocks") 31 | self.formLayout.setWidget(0, QtWidgets.QFormLayout.FieldRole, self.blocks) 32 | self.transfer = QtWidgets.QPushButton(self.dockWidgetContents) 33 | self.transfer.setObjectName("transfer") 34 | self.formLayout.setWidget(1, QtWidgets.QFormLayout.LabelRole, self.transfer) 35 | self.horizontalLayout = QtWidgets.QHBoxLayout() 36 | self.horizontalLayout.setObjectName("horizontalLayout") 37 | self.amount = QtWidgets.QDoubleSpinBox(self.dockWidgetContents) 38 | self.amount.setSuffix("") 39 | self.amount.setMaximum(9999.99) 40 | self.amount.setProperty("value", 10.0) 41 | self.amount.setObjectName("amount") 42 | self.horizontalLayout.addWidget(self.amount) 43 | self.to_label = QtWidgets.QLabel(self.dockWidgetContents) 44 | self.to_label.setObjectName("to_label") 45 | self.horizontalLayout.addWidget(self.to_label) 46 | self.address = QtWidgets.QLineEdit(self.dockWidgetContents) 47 | self.address.setObjectName("address") 48 | self.horizontalLayout.addWidget(self.address) 49 | self.formLayout.setLayout(1, QtWidgets.QFormLayout.FieldRole, self.horizontalLayout) 50 | self.invalidate = QtWidgets.QPushButton(self.dockWidgetContents) 51 | self.invalidate.setObjectName("invalidate") 52 | self.formLayout.setWidget(2, QtWidgets.QFormLayout.LabelRole, self.invalidate) 53 | self.block_hash = QtWidgets.QLineEdit(self.dockWidgetContents) 54 | self.block_hash.setObjectName("block_hash") 55 | self.formLayout.setWidget(2, QtWidgets.QFormLayout.FieldRole, self.block_hash) 56 | BlockchainDock.setWidget(self.dockWidgetContents) 57 | 58 | self.retranslateUi(BlockchainDock) 59 | QtCore.QMetaObject.connectSlotsByName(BlockchainDock) 60 | 61 | def retranslateUi(self, BlockchainDock): 62 | BlockchainDock.setWindowTitle(QtWidgets.QApplication.translate("BlockchainDock", "Blockchain", None, -1)) 63 | self.generate.setText(QtWidgets.QApplication.translate("BlockchainDock", "generate", None, -1)) 64 | self.blocks.setSuffix(QtWidgets.QApplication.translate("BlockchainDock", " block(s)", None, -1)) 65 | self.transfer.setText(QtWidgets.QApplication.translate("BlockchainDock", "transfer", None, -1)) 66 | self.to_label.setText(QtWidgets.QApplication.translate("BlockchainDock", "to", None, -1)) 67 | self.address.setPlaceholderText(QtWidgets.QApplication.translate("BlockchainDock", "recipient address", None, -1)) 68 | self.invalidate.setText(QtWidgets.QApplication.translate("BlockchainDock", "invalidate", None, -1)) 69 | self.block_hash.setPlaceholderText(QtWidgets.QApplication.translate("BlockchainDock", "block hash", None, -1)) 70 | 71 | -------------------------------------------------------------------------------- /torba/workbench/_output_dock.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # Form implementation generated from reading ui file 'output_dock.ui', 4 | # licensing of 'output_dock.ui' applies. 5 | # 6 | # Created: Sat Oct 27 16:41:03 2018 7 | # by: pyside2-uic running on PySide2 5.11.2 8 | # 9 | # WARNING! All changes made in this file will be lost! 10 | 11 | from PySide2 import QtCore, QtGui, QtWidgets 12 | 13 | class Ui_OutputDock(object): 14 | def setupUi(self, OutputDock): 15 | OutputDock.setObjectName("OutputDock") 16 | OutputDock.resize(700, 397) 17 | OutputDock.setFloating(False) 18 | OutputDock.setFeatures(QtWidgets.QDockWidget.AllDockWidgetFeatures) 19 | self.dockWidgetContents = QtWidgets.QWidget() 20 | self.dockWidgetContents.setObjectName("dockWidgetContents") 21 | self.horizontalLayout = QtWidgets.QHBoxLayout(self.dockWidgetContents) 22 | self.horizontalLayout.setObjectName("horizontalLayout") 23 | self.textEdit = QtWidgets.QTextEdit(self.dockWidgetContents) 24 | self.textEdit.setReadOnly(True) 25 | self.textEdit.setObjectName("textEdit") 26 | self.horizontalLayout.addWidget(self.textEdit) 27 | OutputDock.setWidget(self.dockWidgetContents) 28 | 29 | self.retranslateUi(OutputDock) 30 | QtCore.QMetaObject.connectSlotsByName(OutputDock) 31 | 32 | def retranslateUi(self, OutputDock): 33 | OutputDock.setWindowTitle(QtWidgets.QApplication.translate("OutputDock", "Output", None, -1)) 34 | 35 | -------------------------------------------------------------------------------- /torba/workbench/blockchain_dock.ui: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" encoding="UTF-8"?> 2 | <ui version="4.0"> 3 | <class>BlockchainDock</class> 4 | <widget class="QDockWidget" name="BlockchainDock"> 5 | <property name="geometry"> 6 | <rect> 7 | <x>0</x> 8 | <y>0</y> 9 | <width>416</width> 10 | <height>167</height> 11 | </rect> 12 | </property> 13 | <property name="floating"> 14 | <bool>false</bool> 15 | </property> 16 | <property name="features"> 17 | <set>QDockWidget::AllDockWidgetFeatures</set> 18 | </property> 19 | <property name="windowTitle"> 20 | <string>Blockchain</string> 21 | </property> 22 | <widget class="QWidget" name="dockWidgetContents"> 23 | <layout class="QFormLayout" name="formLayout"> 24 | <item row="0" column="0"> 25 | <widget class="QPushButton" name="generate"> 26 | <property name="text"> 27 | <string>generate</string> 28 | </property> 29 | </widget> 30 | </item> 31 | <item row="0" column="1"> 32 | <widget class="QSpinBox" name="blocks"> 33 | <property name="suffix"> 34 | <string> block(s)</string> 35 | </property> 36 | <property name="minimum"> 37 | <number>1</number> 38 | </property> 39 | <property name="maximum"> 40 | <number>9999</number> 41 | </property> 42 | <property name="value"> 43 | <number>1</number> 44 | </property> 45 | </widget> 46 | </item> 47 | <item row="1" column="0"> 48 | <widget class="QPushButton" name="transfer"> 49 | <property name="text"> 50 | <string>transfer</string> 51 | </property> 52 | </widget> 53 | </item> 54 | <item row="1" column="1"> 55 | <layout class="QHBoxLayout" name="horizontalLayout"> 56 | <item> 57 | <widget class="QDoubleSpinBox" name="amount"> 58 | <property name="suffix"> 59 | <string/> 60 | </property> 61 | <property name="maximum"> 62 | <double>9999.989999999999782</double> 63 | </property> 64 | <property name="value"> 65 | <double>10.000000000000000</double> 66 | </property> 67 | </widget> 68 | </item> 69 | <item> 70 | <widget class="QLabel" name="to_label"> 71 | <property name="text"> 72 | <string>to</string> 73 | </property> 74 | </widget> 75 | </item> 76 | <item> 77 | <widget class="QLineEdit" name="address"> 78 | <property name="placeholderText"> 79 | <string>recipient address</string> 80 | </property> 81 | </widget> 82 | </item> 83 | </layout> 84 | </item> 85 | <item row="2" column="0"> 86 | <widget class="QPushButton" name="invalidate"> 87 | <property name="text"> 88 | <string>invalidate</string> 89 | </property> 90 | </widget> 91 | </item> 92 | <item row="2" column="1"> 93 | <widget class="QLineEdit" name="block_hash"> 94 | <property name="placeholderText"> 95 | <string>block hash</string> 96 | </property> 97 | </widget> 98 | </item> 99 | </layout> 100 | </widget> 101 | </widget> 102 | <resources/> 103 | <connections/> 104 | </ui> 105 | -------------------------------------------------------------------------------- /torba/workbench/output_dock.ui: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" encoding="UTF-8"?> 2 | <ui version="4.0"> 3 | <class>OutputDock</class> 4 | <widget class="QDockWidget" name="OutputDock"> 5 | <property name="geometry"> 6 | <rect> 7 | <x>0</x> 8 | <y>0</y> 9 | <width>700</width> 10 | <height>397</height> 11 | </rect> 12 | </property> 13 | <property name="floating"> 14 | <bool>false</bool> 15 | </property> 16 | <property name="features"> 17 | <set>QDockWidget::AllDockWidgetFeatures</set> 18 | </property> 19 | <property name="windowTitle"> 20 | <string>Output</string> 21 | </property> 22 | <widget class="QWidget" name="dockWidgetContents"> 23 | <layout class="QHBoxLayout" name="horizontalLayout"> 24 | <item> 25 | <widget class="QTextEdit" name="textEdit"> 26 | <property name="readOnly"> 27 | <bool>true</bool> 28 | </property> 29 | </widget> 30 | </item> 31 | </layout> 32 | </widget> 33 | </widget> 34 | <resources/> 35 | <connections/> 36 | </ui> 37 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | #envlist = unit,integration-{torba.coin.bitcoincash,torba.coin.bitcoinsegwit} 3 | envlist = py37-unit,py37-integration-torba.coin.bitcoinsegwit 4 | 5 | [travis:env] 6 | TESTTYPE = 7 | unit: unit 8 | integration: integration 9 | 10 | [testenv] 11 | deps = coverage 12 | changedir = {toxinidir}/tests 13 | setenv = 14 | integration: TORBA_LEDGER={envname} 15 | commands = 16 | unit: coverage run -p --source={envsitepackagesdir}/torba -m unittest discover -t . client_tests.unit 17 | integration: orchstr8 download 18 | integration: coverage run -p --source={envsitepackagesdir}/torba -m unittest discover -t . client_tests.integration 19 | --------------------------------------------------------------------------------