From 5e71dcbaf0561ffa1f628b18fef743eecae574f1 Mon Sep 17 00:00:00 2001 From: Lex Berezhny Date: Mon, 30 Apr 2018 03:04:52 -0400 Subject: [PATCH] wip: implementation is now generic and supports multiple currencies --- lbrynet/conf.py | 1 + lbrynet/tests/integration/test_wallet.py | 59 ++--- lbrynet/tests/unit/wallet/test_account.py | 99 ++++++++ .../tests/unit/wallet/test_coinselection.py | 24 +- lbrynet/tests/unit/wallet/test_ledger.py | 0 lbrynet/tests/unit/wallet/test_script.py | 27 ++- lbrynet/tests/unit/wallet/test_transaction.py | 135 ++++++----- lbrynet/tests/unit/wallet/test_wallet.py | 120 +++++----- lbrynet/wallet/__init__.py | 11 +- lbrynet/wallet/account.py | 148 ++++++++---- lbrynet/wallet/basecoin.py | 83 +++++++ lbrynet/wallet/{ledger.py => baseledger.py} | 206 ++++++++++++---- .../wallet/{protocol.py => basenetwork.py} | 6 +- lbrynet/wallet/{script.py => basescript.py} | 83 +------ .../{transaction.py => basetransaction.py} | 221 +++++++----------- lbrynet/wallet/bip32.py | 55 +++-- lbrynet/wallet/coins/__init__.py | 2 + lbrynet/wallet/coins/bitcoin.py | 43 ++++ lbrynet/wallet/coins/lbc/__init__.py | 1 + lbrynet/wallet/coins/lbc/coin.py | 67 ++++++ lbrynet/wallet/coins/lbc/ledger.py | 28 +++ lbrynet/wallet/coins/lbc/network.py | 5 + lbrynet/wallet/coins/lbc/script.py | 80 +++++++ lbrynet/wallet/coins/lbc/transaction.py | 34 +++ lbrynet/wallet/constants.py | 56 ----- lbrynet/wallet/hash.py | 22 -- lbrynet/wallet/manager.py | 188 ++++++--------- lbrynet/wallet/util.py | 22 ++ lbrynet/wallet/wallet.py | 185 +++++++++------ 29 files changed, 1214 insertions(+), 797 deletions(-) create mode 100644 lbrynet/tests/unit/wallet/test_account.py create mode 100644 lbrynet/tests/unit/wallet/test_ledger.py create mode 100644 lbrynet/wallet/basecoin.py rename lbrynet/wallet/{ledger.py => baseledger.py} (58%) rename lbrynet/wallet/{protocol.py => basenetwork.py} (97%) rename lbrynet/wallet/{script.py => basescript.py} (81%) rename lbrynet/wallet/{transaction.py => basetransaction.py} (52%) create mode 100644 lbrynet/wallet/coins/__init__.py create mode 100644 lbrynet/wallet/coins/bitcoin.py create mode 100644 lbrynet/wallet/coins/lbc/__init__.py create mode 100644 lbrynet/wallet/coins/lbc/coin.py create mode 100644 lbrynet/wallet/coins/lbc/ledger.py create mode 100644 lbrynet/wallet/coins/lbc/network.py create mode 100644 lbrynet/wallet/coins/lbc/script.py create mode 100644 lbrynet/wallet/coins/lbc/transaction.py diff --git a/lbrynet/conf.py b/lbrynet/conf.py index 2964db29e..74273a2fd 100644 --- a/lbrynet/conf.py +++ b/lbrynet/conf.py @@ -29,6 +29,7 @@ ENV_NAMESPACE = 'LBRY_' LBRYCRD_WALLET = 'lbrycrd' LBRYUM_WALLET = 'lbryum' PTC_WALLET = 'ptc' +TORBA_WALLET = 'torba' PROTOCOL_PREFIX = 'lbry' APP_NAME = 'LBRY' diff --git a/lbrynet/tests/integration/test_wallet.py b/lbrynet/tests/integration/test_wallet.py index dc7e15b56..f141f5e90 100644 --- a/lbrynet/tests/integration/test_wallet.py +++ b/lbrynet/tests/integration/test_wallet.py @@ -6,17 +6,14 @@ from binascii import hexlify from twisted.internet import defer, reactor, threads from twisted.trial import unittest -from orchstr8.wrapper import BaseLbryServiceStack +from orchstr8.services import BaseLbryServiceStack from lbrynet.core.call_later_manager import CallLaterManager from lbrynet.database.storage import SQLiteStorage -from lbrynet.wallet import set_wallet_manager -from lbrynet.wallet.wallet import Wallet +from lbrynet.wallet.basecoin import CoinRegistry from lbrynet.wallet.manager import WalletManager -from lbrynet.wallet.transaction import Transaction, Output -from lbrynet.wallet.constants import COIN, REGTEST_CHAIN -from lbrynet.wallet.hash import hash160_to_address, address_to_hash_160 +from lbrynet.wallet.constants import COIN class WalletTestCase(unittest.TestCase): @@ -27,11 +24,6 @@ class WalletTestCase(unittest.TestCase): logging.getLogger('lbrynet').setLevel(logging.INFO) self.data_path = tempfile.mkdtemp() self.db = SQLiteStorage(self.data_path) - self.config = { - 'chain': REGTEST_CHAIN, - 'wallet_path': self.data_path, - 'default_servers': [('localhost', 50001)] - } CallLaterManager.setup(reactor.callLater) self.service = BaseLbryServiceStack(self.VERBOSE) return self.service.startup() @@ -52,37 +44,30 @@ class StartupTests(WalletTestCase): @defer.inlineCallbacks def test_balance(self): - wallet = Wallet(chain=REGTEST_CHAIN) - manager = WalletManager(self.config, wallet) - set_wallet_manager(manager) - yield manager.start() - yield self.lbrycrd.generate(1) - yield threads.deferToThread(time.sleep, 1) - #yield wallet.network.on_header.first - address = manager.get_least_used_receiving_address() + coin_id = 'lbc_regtest' + manager = WalletManager.from_config({ + 'ledgers': {coin_id: {'default_servers': [('localhost', 50001)]}} + }) + wallet = manager.create_wallet(None, CoinRegistry.get_coin_class(coin_id)) + ledger = manager.ledgers.values()[0] + account = wallet.default_account + coin = account.coin + yield manager.start_ledgers() + address = account.get_least_used_receiving_address() sendtxid = yield self.lbrycrd.sendtoaddress(address, 2.5) yield self.lbrycrd.generate(1) #yield manager.wallet.history.on_transaction. yield threads.deferToThread(time.sleep, 10) - tx = manager.ledger.transactions.values()[0] - print(tx.to_python_source()) - print(address) - output = None - for txo in tx.outputs: - other = hash160_to_address(txo.script.values['pubkey_hash'], 'regtest') - if other == address: - output = txo - break - - address2 = manager.get_least_used_receiving_address() - tx = Transaction() - tx.add_inputs([output.spend()]) - Output.pay_pubkey_hash(tx, 0, 2.49*COIN, address_to_hash_160(address2)) - print(tx.to_python_source()) - tx.sign(wallet) - print(tx.to_python_source()) + utxo = account.get_unspent_utxos()[0] + address2 = account.get_least_used_receiving_address() + tx_class = ledger.transaction_class + Input, Output = tx_class.input_class, tx_class.output_class + tx = tx_class()\ + .add_inputs([Input.spend(utxo)])\ + .add_outputs([Output.pay_pubkey_hash(2.49*COIN, coin.address_to_hash160(address2))])\ + .sign(account) yield self.lbrycrd.decoderawtransaction(hexlify(tx.raw)) yield self.lbrycrd.sendrawtransaction(hexlify(tx.raw)) - yield manager.stop() + yield manager.stop_ledgers() diff --git a/lbrynet/tests/unit/wallet/test_account.py b/lbrynet/tests/unit/wallet/test_account.py new file mode 100644 index 000000000..1e97f61e7 --- /dev/null +++ b/lbrynet/tests/unit/wallet/test_account.py @@ -0,0 +1,99 @@ +from twisted.trial import unittest + +from lbrynet.wallet.coins.lbc import LBC +from lbrynet.wallet.manager import WalletManager +from lbrynet.wallet.wallet import Account + + +class TestAccount(unittest.TestCase): + + def setUp(self): + coin = LBC() + ledger = coin.ledger_class + WalletManager([], {ledger: ledger(coin)}).install() + self.coin = coin + + def test_generate_account(self): + account = Account.generate(self.coin) + self.assertEqual(account.coin, self.coin) + self.assertIsNotNone(account.seed) + self.assertEqual(account.public_key.coin, self.coin) + self.assertEqual(account.private_key.public_key, account.public_key) + + self.assertEqual(len(account.receiving_keys.child_keys), 0) + self.assertEqual(len(account.receiving_keys.addresses), 0) + self.assertEqual(len(account.change_keys.child_keys), 0) + self.assertEqual(len(account.change_keys.addresses), 0) + + account.ensure_enough_addresses() + self.assertEqual(len(account.receiving_keys.child_keys), 20) + self.assertEqual(len(account.receiving_keys.addresses), 20) + self.assertEqual(len(account.change_keys.child_keys), 6) + self.assertEqual(len(account.change_keys.addresses), 6) + + def test_generate_account_from_seed(self): + account = Account.from_seed( + self.coin, + "carbon smart garage balance margin twelve chest sword toast envelope bottom stomach ab" + "sent" + ) + self.assertEqual( + account.private_key.extended_key_string(), + 'LprvXPsFZUGgrX1X9HiyxABZSf6hWJK7kHv4zGZRyyiHbBq5Wu94cE1DMvttnpLYReTPNW4eYwX9dWMvTz3PrB' + 'wwbRafEeA1ZXL69U2egM4QJdq' + ) + self.assertEqual( + account.public_key.extended_key_string(), + 'Lpub2hkYkGHXktBhLpwUhKKogyuJ1M7Gt9EkjFTVKyDqZiZpWdhLuCoT1eKDfXfysMFfG4SzfXXcA2SsHzrjHK' + 'Ea5aoCNRBAhjT5NPLV6hXtvEi' + ) + self.assertEqual( + account.receiving_keys.generate_next_address(), + 'bCqJrLHdoiRqEZ1whFZ3WHNb33bP34SuGx' + ) + private_key = account.get_private_key_for_address('bCqJrLHdoiRqEZ1whFZ3WHNb33bP34SuGx') + self.assertEqual( + private_key.extended_key_string(), + 'LprvXTnmVLXGKvRGo2ihBE6LJ771G3VVpAx2zhTJvjnx5P3h6iZ4VJX8PvwTcgzJZ1hqXX61Wpn4pQoP6n2wgp' + 'S8xjzCM6H2uGzCXuAMy5H9vtA' + ) + self.assertIsNone(account.get_private_key_for_address('BcQjRlhDOIrQez1WHfz3whnB33Bp34sUgX')) + + def test_load_and_save_account(self): + account_data = { + 'seed': + "carbon smart garage balance margin twelve chest sword toast envelope bottom stomac" + "h absent", + 'encrypted': False, + 'private_key': + 'LprvXPsFZUGgrX1X9HiyxABZSf6hWJK7kHv4zGZRyyiHbBq5Wu94cE1DMvttnpLYReTPNW4eYwX9dWMvTz3PrB' + 'wwbRafEeA1ZXL69U2egM4QJdq', + 'public_key': + 'Lpub2hkYkGHXktBhLpwUhKKogyuJ1M7Gt9EkjFTVKyDqZiZpWdhLuCoT1eKDfXfysMFfG4SzfXXcA2SsHzrjHK' + 'Ea5aoCNRBAhjT5NPLV6hXtvEi', + 'receiving_gap': 10, + 'receiving_keys': [ + '02c68e2d1cf85404c86244ffa279f4c5cd00331e996d30a86d6e46480e3a9220f4', + '03c5a997d0549875d23b8e4bbc7b4d316d962587483f3a2e62ddd90a21043c4941' + ], + 'change_gap': 10, + 'change_keys': [ + '021460e8d728eee325d0d43128572b2e2bacdc027e420451df100cf9f2154ea5ab' + ] + } + + account = Account.from_dict(self.coin, account_data) + + self.assertEqual(len(account.receiving_keys.addresses), 2) + self.assertEqual( + account.receiving_keys.addresses[0], + 'bCqJrLHdoiRqEZ1whFZ3WHNb33bP34SuGx' + ) + self.assertEqual(len(account.change_keys.addresses), 1) + self.assertEqual( + account.change_keys.addresses[0], + 'bFpHENtqugKKHDshKFq2Mnb59Y2bx4vKgL' + ) + + account_data['coin'] = 'lbc_mainnet' + self.assertDictEqual(account_data, account.to_dict()) diff --git a/lbrynet/tests/unit/wallet/test_coinselection.py b/lbrynet/tests/unit/wallet/test_coinselection.py index 06d502b0a..470046af6 100644 --- a/lbrynet/tests/unit/wallet/test_coinselection.py +++ b/lbrynet/tests/unit/wallet/test_coinselection.py @@ -1,10 +1,12 @@ import unittest -from lbrynet.wallet.constants import CENT, MAXIMUM_FEE_PER_BYTE -from lbrynet.wallet.transaction import Transaction, Output +from lbrynet.wallet.coins.lbc.lbc import LBRYCredits +from lbrynet.wallet.coins.bitcoin import Bitcoin from lbrynet.wallet.coinselection import CoinSelector, MAXIMUM_TRIES +from lbrynet.wallet.constants import CENT from lbrynet.wallet.manager import WalletManager -from lbrynet.wallet import set_wallet_manager + +from .test_transaction import get_output as utxo NULL_HASH = '\x00'*32 @@ -15,20 +17,18 @@ def search(*args, **kwargs): return [o.amount for o in selection] if selection else selection -def utxo(amount): - return Output.pay_pubkey_hash(Transaction(), 0, amount, NULL_HASH) - - class TestCoinSelectionTests(unittest.TestCase): def setUp(self): - set_wallet_manager(WalletManager({'fee_per_byte': MAXIMUM_FEE_PER_BYTE})) + WalletManager([], { + LBRYCredits.ledger_class: LBRYCredits.ledger_class(LBRYCredits), + }).install() def test_empty_coins(self): self.assertIsNone(CoinSelector([], 0, 0).select()) def test_skip_binary_search_if_total_not_enough(self): - fee = utxo(CENT).spend(fake=True).fee + fee = utxo(CENT).spend().fee big_pool = [utxo(CENT+fee) for _ in range(100)] selector = CoinSelector(big_pool, 101 * CENT, 0) self.assertIsNone(selector.select()) @@ -39,7 +39,7 @@ class TestCoinSelectionTests(unittest.TestCase): self.assertEqual(selector.tries, 201) def test_exact_match(self): - fee = utxo(CENT).spend(fake=True).fee + fee = utxo(CENT).spend().fee utxo_pool = [ utxo(CENT + fee), utxo(CENT), @@ -74,7 +74,9 @@ class TestOfficialBitcoinCoinSelectionTests(unittest.TestCase): # https://murch.one/wp-content/uploads/2016/11/erhardt2016coinselection.pdf def setUp(self): - set_wallet_manager(WalletManager({'fee_per_byte': 0})) + WalletManager([], { + Bitcoin.ledger_class: Bitcoin.ledger_class(Bitcoin), + }).install() def make_hard_case(self, utxos): target = 0 diff --git a/lbrynet/tests/unit/wallet/test_ledger.py b/lbrynet/tests/unit/wallet/test_ledger.py new file mode 100644 index 000000000..e69de29bb diff --git a/lbrynet/tests/unit/wallet/test_script.py b/lbrynet/tests/unit/wallet/test_script.py index c4ef366fa..d0a05505c 100644 --- a/lbrynet/tests/unit/wallet/test_script.py +++ b/lbrynet/tests/unit/wallet/test_script.py @@ -1,9 +1,11 @@ from binascii import hexlify, unhexlify from twisted.trial import unittest -from lbrynet.wallet.script import Template, ParseError, tokenize, push_data -from lbrynet.wallet.script import PUSH_SINGLE, PUSH_MANY, OP_HASH160, OP_EQUAL -from lbrynet.wallet.script import InputScript, OutputScript + from lbrynet.wallet.bcd_data_stream import BCDataStream +from lbrynet.wallet.basescript import Template, ParseError, tokenize, push_data +from lbrynet.wallet.basescript import PUSH_SINGLE, PUSH_MANY, OP_HASH160, OP_EQUAL +from lbrynet.wallet.basescript import BaseInputScript, BaseOutputScript +from lbrynet.wallet.coins.lbc.script import OutputScript def parse(opcodes, source): @@ -100,12 +102,12 @@ class TestRedeemPubKeyHash(unittest.TestCase): def redeem_pubkey_hash(self, sig, pubkey): # this checks that factory function correctly sets up the script - src1 = InputScript.redeem_pubkey_hash(unhexlify(sig), unhexlify(pubkey)) + src1 = BaseInputScript.redeem_pubkey_hash(unhexlify(sig), unhexlify(pubkey)) self.assertEqual(src1.template.name, 'pubkey_hash') self.assertEqual(hexlify(src1.values['signature']), sig) self.assertEqual(hexlify(src1.values['pubkey']), pubkey) # now we test that it will round trip - src2 = InputScript(src1.source) + src2 = BaseInputScript(src1.source) self.assertEqual(src2.template.name, 'pubkey_hash') self.assertEqual(hexlify(src2.values['signature']), sig) self.assertEqual(hexlify(src2.values['pubkey']), pubkey) @@ -128,7 +130,7 @@ class TestRedeemScriptHash(unittest.TestCase): def redeem_script_hash(self, sigs, pubkeys): # this checks that factory function correctly sets up the script - src1 = InputScript.redeem_script_hash( + src1 = BaseInputScript.redeem_script_hash( [unhexlify(sig) for sig in sigs], [unhexlify(pubkey) for pubkey in pubkeys] ) @@ -139,7 +141,7 @@ class TestRedeemScriptHash(unittest.TestCase): self.assertEqual(subscript1.values['signatures_count'], len(sigs)) self.assertEqual(subscript1.values['pubkeys_count'], len(pubkeys)) # now we test that it will round trip - src2 = InputScript(src1.source) + src2 = BaseInputScript(src1.source) subscript2 = src2.values['script'] self.assertEqual(src2.template.name, 'script_hash') self.assertEqual([hexlify(v) for v in src2.values['signatures']], sigs) @@ -181,11 +183,11 @@ class TestPayPubKeyHash(unittest.TestCase): def pay_pubkey_hash(self, pubkey_hash): # this checks that factory function correctly sets up the script - src1 = OutputScript.pay_pubkey_hash(unhexlify(pubkey_hash)) + src1 = BaseOutputScript.pay_pubkey_hash(unhexlify(pubkey_hash)) self.assertEqual(src1.template.name, 'pay_pubkey_hash') self.assertEqual(hexlify(src1.values['pubkey_hash']), pubkey_hash) # now we test that it will round trip - src2 = OutputScript(src1.source) + src2 = BaseOutputScript(src1.source) self.assertEqual(src2.template.name, 'pay_pubkey_hash') self.assertEqual(hexlify(src2.values['pubkey_hash']), pubkey_hash) return hexlify(src1.source) @@ -201,11 +203,11 @@ class TestPayScriptHash(unittest.TestCase): def pay_script_hash(self, script_hash): # this checks that factory function correctly sets up the script - src1 = OutputScript.pay_script_hash(unhexlify(script_hash)) + src1 = BaseOutputScript.pay_script_hash(unhexlify(script_hash)) self.assertEqual(src1.template.name, 'pay_script_hash') self.assertEqual(hexlify(src1.values['script_hash']), script_hash) # now we test that it will round trip - src2 = OutputScript(src1.source) + src2 = BaseOutputScript(src1.source) self.assertEqual(src2.template.name, 'pay_script_hash') self.assertEqual(hexlify(src2.values['script_hash']), script_hash) return hexlify(src1.source) @@ -221,7 +223,8 @@ class TestPayClaimNamePubkeyHash(unittest.TestCase): def pay_claim_name_pubkey_hash(self, name, claim, pubkey_hash): # this checks that factory function correctly sets up the script - src1 = OutputScript.pay_claim_name_pubkey_hash(name, unhexlify(claim), unhexlify(pubkey_hash)) + src1 = OutputScript.pay_claim_name_pubkey_hash( + name, unhexlify(claim), unhexlify(pubkey_hash)) self.assertEqual(src1.template.name, 'claim_name+pay_pubkey_hash') self.assertEqual(src1.values['claim_name'], name) self.assertEqual(hexlify(src1.values['claim']), claim) diff --git a/lbrynet/tests/unit/wallet/test_transaction.py b/lbrynet/tests/unit/wallet/test_transaction.py index 22268e4db..883342acf 100644 --- a/lbrynet/tests/unit/wallet/test_transaction.py +++ b/lbrynet/tests/unit/wallet/test_transaction.py @@ -1,11 +1,12 @@ from binascii import hexlify, unhexlify from twisted.trial import unittest -from lbrynet.wallet.constants import CENT -from lbrynet.wallet.transaction import Transaction, Input, Output + +from lbrynet.wallet.account import Account +from lbrynet.wallet.coins.lbc import LBC +from lbrynet.wallet.coins.lbc.transaction import Transaction, Output, Input +from lbrynet.wallet.constants import CENT, COIN from lbrynet.wallet.manager import WalletManager -from lbrynet.wallet import set_wallet_manager -from lbrynet.wallet.bip32 import PrivateKey -from lbrynet.wallet.mnemonic import Mnemonic +from lbrynet.wallet.wallet import Wallet NULL_HASH = '\x00'*32 @@ -13,68 +14,78 @@ FEE_PER_BYTE = 50 FEE_PER_CHAR = 200000 +def get_output(amount=CENT, pubkey_hash=NULL_HASH): + return Transaction() \ + .add_outputs([Output.pay_pubkey_hash(amount, pubkey_hash)]) \ + .outputs[0] + + +def get_input(): + return Input.spend(get_output()) + + +def get_transaction(txo=None): + return Transaction() \ + .add_inputs([get_input()]) \ + .add_outputs([txo or Output.pay_pubkey_hash(CENT, NULL_HASH)]) + + +def get_claim_transaction(claim_name, claim=''): + return get_transaction( + Output.pay_claim_name_pubkey_hash(CENT, claim_name, claim, NULL_HASH) + ) + + +def get_lbc_wallet(): + lbc = LBC.from_dict({ + 'fee_per_byte': FEE_PER_BYTE, + 'fee_per_name_char': FEE_PER_CHAR + }) + return Wallet('Main', [lbc], [Account.generate(lbc)]) + + class TestSizeAndFeeEstimation(unittest.TestCase): def setUp(self): - set_wallet_manager(WalletManager({ - 'fee_per_byte': FEE_PER_BYTE, - 'fee_per_name_char': FEE_PER_CHAR - })) + self.wallet = get_lbc_wallet() + self.coin = self.wallet.coins[0] + WalletManager([self.wallet], {}) - @staticmethod - def get_output(): - return Output.pay_pubkey_hash(Transaction(), 1, CENT, NULL_HASH) - - @classmethod - def get_input(cls): - return cls.get_output().spend(fake=True) - - @classmethod - def get_transaction(cls): - tx = Transaction() - Output.pay_pubkey_hash(tx, 1, CENT, NULL_HASH) - tx.add_inputs([cls.get_input()]) - return tx - - @classmethod - def get_claim_transaction(cls, claim_name, claim=''): - tx = Transaction() - Output.pay_claim_name_pubkey_hash(tx, 1, CENT, claim_name, claim, NULL_HASH) - tx.add_inputs([cls.get_input()]) - return tx + def io_fee(self, io): + return self.coin.get_input_output_fee(io) def test_output_size_and_fee(self): - txo = self.get_output() + txo = get_output() self.assertEqual(txo.size, 46) - self.assertEqual(txo.fee, 46 * FEE_PER_BYTE) + self.assertEqual(self.io_fee(txo), 46 * FEE_PER_BYTE) def test_input_size_and_fee(self): - txi = self.get_input() + txi = get_input() self.assertEqual(txi.size, 148) - self.assertEqual(txi.fee, 148 * FEE_PER_BYTE) + self.assertEqual(self.io_fee(txi), 148 * FEE_PER_BYTE) def test_transaction_size_and_fee(self): - tx = self.get_transaction() + tx = get_transaction() base_size = tx.size - 1 - tx.inputs[0].size self.assertEqual(tx.size, 204) self.assertEqual(tx.base_size, base_size) - self.assertEqual(tx.base_fee, FEE_PER_BYTE * base_size) + self.assertEqual(self.coin.get_transaction_base_fee(tx), FEE_PER_BYTE * base_size) def test_claim_name_transaction_size_and_fee(self): # fee based on claim name is the larger fee claim_name = 'verylongname' - tx = self.get_claim_transaction(claim_name, '0'*4000) + tx = get_claim_transaction(claim_name, '0'*4000) base_size = tx.size - 1 - tx.inputs[0].size self.assertEqual(tx.size, 4225) self.assertEqual(tx.base_size, base_size) - self.assertEqual(tx.base_fee, len(claim_name) * FEE_PER_CHAR) + self.assertEqual(self.coin.get_transaction_base_fee(tx), len(claim_name) * FEE_PER_CHAR) # fee based on total bytes is the larger fee claim_name = 'a' - tx = self.get_claim_transaction(claim_name, '0'*4000) + tx = get_claim_transaction(claim_name, '0'*4000) base_size = tx.size - 1 - tx.inputs[0].size self.assertEqual(tx.size, 4214) self.assertEqual(tx.base_size, base_size) - self.assertEqual(tx.base_fee, FEE_PER_BYTE * base_size) + self.assertEqual(self.coin.get_transaction_base_fee(tx), FEE_PER_BYTE * base_size) class TestTransactionSerialization(unittest.TestCase): @@ -92,7 +103,7 @@ class TestTransactionSerialization(unittest.TestCase): self.assertEqual(len(tx.outputs), 1) coinbase = tx.inputs[0] - self.assertEqual(coinbase.output_tx_hash, NULL_HASH) + self.assertEqual(coinbase.output_txid, NULL_HASH) self.assertEqual(coinbase.output_index, 0xFFFFFFFF) self.assertEqual(coinbase.sequence, 0xFFFFFFFF) self.assertTrue(coinbase.is_coinbase) @@ -125,7 +136,7 @@ class TestTransactionSerialization(unittest.TestCase): self.assertEqual(len(tx.outputs), 1) coinbase = tx.inputs[0] - self.assertEqual(coinbase.output_tx_hash, NULL_HASH) + self.assertEqual(coinbase.output_txid, NULL_HASH) self.assertEqual(coinbase.output_index, 0xFFFFFFFF) self.assertEqual(coinbase.sequence, 0) self.assertTrue(coinbase.is_coinbase) @@ -166,9 +177,9 @@ class TestTransactionSerialization(unittest.TestCase): self.assertEqual(len(tx.inputs), 1) self.assertEqual(len(tx.outputs), 2) - txin = tx.inputs[0] # type: Input + txin = tx.inputs[0] self.assertEqual( - hexlify(txin.output_tx_hash[::-1]), + hexlify(txin.output_txid[::-1]), b'1dfd535b6c8550ebe95abceb877f92f76f30e5ba4d3483b043386027b3e13324' ) self.assertEqual(txin.output_index, 0) @@ -186,7 +197,7 @@ class TestTransactionSerialization(unittest.TestCase): ) # Claim - out0 = tx.outputs[0] # type: Output + out0 = tx.outputs[0] self.assertEqual(out0.amount, 10000000) self.assertEqual(out0.index, 0) self.assertTrue(out0.script.is_pay_pubkey_hash) @@ -199,7 +210,7 @@ class TestTransactionSerialization(unittest.TestCase): ) # Change - out1 = tx.outputs[1] # type: Output + out1 = tx.outputs[1] self.assertEqual(out1.amount, 189977100) self.assertEqual(out1.index, 1) self.assertTrue(out1.script.is_pay_pubkey_hash) @@ -215,15 +226,27 @@ class TestTransactionSerialization(unittest.TestCase): class TestTransactionSigning(unittest.TestCase): - def setUp(self): - self.private_key = PrivateKey.from_seed(Mnemonic.mnemonic_to_seed( - 'program leader library giant team normal suspect crater pair miracle sweet until absent' - )) - def test_sign(self): - tx = Transaction() - Output.pay_pubkey_hash(Transaction(), 0, CENT, NULL_HASH).spend(fake=True) - tx.add_inputs([self.get_input()]) - Output.pay_pubkey_hash(tx, 0, CENT, NULL_HASH) - tx = self.get_tx() + lbc = LBC() + wallet = Wallet('Main', [lbc], [Account.from_seed( + lbc, 'carbon smart garage balance margin twelve chest sword toast envelope ' + 'bottom stomach absent' + )]) + account = wallet.default_account + address1 = account.receiving_keys.generate_next_address() + address2 = account.receiving_keys.generate_next_address() + pubkey_hash1 = account.coin.address_to_hash160(address1) + pubkey_hash2 = account.coin.address_to_hash160(address2) + + tx = Transaction() \ + .add_inputs([Input.spend(get_output(2*COIN, pubkey_hash1))]) \ + .add_outputs([Output.pay_pubkey_hash(1.9*COIN, pubkey_hash2)]) \ + .sign(account) + + print(hexlify(tx.inputs[0].script.values['signature'])) + self.assertEqual( + hexlify(tx.inputs[0].script.values['signature']), + b'304402200dafa26ad7cf38c5a971c8a25ce7d85a076235f146126762296b1223c42ae21e022020ef9eeb8' + b'398327891008c5c0be4357683f12cb22346691ff23914f457bf679601' + ) diff --git a/lbrynet/tests/unit/wallet/test_wallet.py b/lbrynet/tests/unit/wallet/test_wallet.py index e58586f0b..65f007472 100644 --- a/lbrynet/tests/unit/wallet/test_wallet.py +++ b/lbrynet/tests/unit/wallet/test_wallet.py @@ -1,83 +1,84 @@ from twisted.trial import unittest -from lbrynet.wallet.wallet import Account, Wallet + +from lbrynet.wallet.coins.bitcoin import BTC +from lbrynet.wallet.coins.lbc import LBC from lbrynet.wallet.manager import WalletManager -from lbrynet.wallet import set_wallet_manager +from lbrynet.wallet.wallet import Account, Wallet, WalletStorage -class TestWalletAccount(unittest.TestCase): +class TestWalletCreation(unittest.TestCase): - def test_wallet_automatically_creates_default_account(self): + def setUp(self): + WalletManager([], { + LBC.ledger_class: LBC.ledger_class(LBC), + BTC.ledger_class: BTC.ledger_class(BTC) + }).install() + self.coin = LBC() + + def test_create_wallet_and_accounts(self): wallet = Wallet() - set_wallet_manager(WalletManager(wallet=wallet)) - account = wallet.default_account # type: Account - self.assertIsInstance(account, Account) - self.assertEqual(len(account.receiving_keys.child_keys), 0) - self.assertEqual(len(account.receiving_keys.addresses), 0) - self.assertEqual(len(account.change_keys.child_keys), 0) - self.assertEqual(len(account.change_keys.addresses), 0) + self.assertEqual(wallet.name, 'Wallet') + self.assertEqual(wallet.coins, []) + self.assertEqual(wallet.accounts, []) + + account1 = wallet.generate_account(LBC) + account2 = wallet.generate_account(LBC) + account3 = wallet.generate_account(BTC) + self.assertEqual(wallet.default_account, account1) + self.assertEqual(len(wallet.coins), 2) + self.assertEqual(len(wallet.accounts), 3) + self.assertIsInstance(wallet.coins[0], LBC) + self.assertIsInstance(wallet.coins[1], BTC) + + self.assertEqual(len(account1.receiving_keys.addresses), 0) + self.assertEqual(len(account1.change_keys.addresses), 0) + self.assertEqual(len(account2.receiving_keys.addresses), 0) + self.assertEqual(len(account2.change_keys.addresses), 0) + self.assertEqual(len(account3.receiving_keys.addresses), 0) + self.assertEqual(len(account3.change_keys.addresses), 0) wallet.ensure_enough_addresses() - self.assertEqual(len(account.receiving_keys.child_keys), 20) - self.assertEqual(len(account.receiving_keys.addresses), 20) - self.assertEqual(len(account.change_keys.child_keys), 6) - self.assertEqual(len(account.change_keys.addresses), 6) + self.assertEqual(len(account1.receiving_keys.addresses), 20) + self.assertEqual(len(account1.change_keys.addresses), 6) + self.assertEqual(len(account2.receiving_keys.addresses), 20) + self.assertEqual(len(account2.change_keys.addresses), 6) + self.assertEqual(len(account3.receiving_keys.addresses), 20) + self.assertEqual(len(account3.change_keys.addresses), 6) - def test_generate_account_from_seed(self): - account = Account.generate_from_seed( - "carbon smart garage balance margin twelve chest sword toast envelope bottom stomach ab" - "sent" - ) # type: Account - self.assertEqual( - account.private_key.extended_key_string(), - "xprv9s21ZrQH143K42ovpZygnjfHdAqSd9jo7zceDfPRogM7bkkoNVv7DRNLEoB8HoirMgH969NrgL8jNzLEeg" - "qFzPRWM37GXd4uE8uuRkx4LAe", - ) - self.assertEqual( - account.public_key.extended_key_string(), - "xpub661MyMwAqRbcGWtPvbWh9sc2BCfw2cTeVDYF23o3N1t6UZ5wv3EMmDgp66FxHuDtWdft3B5eL5xQtyzAtk" - "dmhhC95gjRjLzSTdkho95asu9", - ) - self.assertEqual( - account.receiving_keys.generate_next_address(), - 'bCqJrLHdoiRqEZ1whFZ3WHNb33bP34SuGx' - ) - private_key = account.get_private_key_for_address('bCqJrLHdoiRqEZ1whFZ3WHNb33bP34SuGx') - self.assertEqual( - private_key.extended_key_string(), - 'xprv9vwXVierUTT4hmoe3dtTeBfbNv1ph2mm8RWXARU6HsZjBaAoFaS2FRQu4fptRAyJWhJW42dmsEaC1nKnVK' - 'KTMhq3TVEHsNj1ca3ciZMKktT' - ) - self.assertIsNone(account.get_private_key_for_address('BcQjRlhDOIrQez1WHfz3whnB33Bp34sUgX')) - - def test_load_and_save_account(self): - wallet_data = { + def test_load_and_save_wallet(self): + wallet_dict = { 'name': 'Main Wallet', - 'accounts': { - 0: { + 'accounts': [ + { + 'coin': 'lbc_mainnet', 'seed': "carbon smart garage balance margin twelve chest sword toast envelope botto" "m stomach absent", 'encrypted': False, 'private_key': - "xprv9s21ZrQH143K42ovpZygnjfHdAqSd9jo7zceDfPRogM7bkkoNVv7DRNLEoB8HoirMgH969" - "NrgL8jNzLEegqFzPRWM37GXd4uE8uuRkx4LAe", + 'LprvXPsFZUGgrX1X9HiyxABZSf6hWJK7kHv4zGZRyyiHbBq5Wu94cE1DMvttnpLYReTPNW4eYwX9dWMvTz3PrB' + 'wwbRafEeA1ZXL69U2egM4QJdq', 'public_key': - "xpub661MyMwAqRbcGWtPvbWh9sc2BCfw2cTeVDYF23o3N1t6UZ5wv3EMmDgp66FxHuDtWdft3B" - "5eL5xQtyzAtkdmhhC95gjRjLzSTdkho95asu9", + 'Lpub2hkYkGHXktBhLpwUhKKogyuJ1M7Gt9EkjFTVKyDqZiZpWdhLuCoT1eKDfXfysMFfG4SzfXXcA2SsHzrjHK' + 'Ea5aoCNRBAhjT5NPLV6hXtvEi', 'receiving_gap': 10, 'receiving_keys': [ '02c68e2d1cf85404c86244ffa279f4c5cd00331e996d30a86d6e46480e3a9220f4', - '03c5a997d0549875d23b8e4bbc7b4d316d962587483f3a2e62ddd90a21043c4941'], + '03c5a997d0549875d23b8e4bbc7b4d316d962587483f3a2e62ddd90a21043c4941' + ], 'change_gap': 10, 'change_keys': [ - '021460e8d728eee325d0d43128572b2e2bacdc027e420451df100cf9f2154ea5ab'] + '021460e8d728eee325d0d43128572b2e2bacdc027e420451df100cf9f2154ea5ab' + ] } - } + ] } - wallet = Wallet.from_json(wallet_data) - set_wallet_manager(WalletManager(wallet=wallet)) + storage = WalletStorage(default=wallet_dict) + wallet = Wallet.from_storage(storage) self.assertEqual(wallet.name, 'Main Wallet') - + self.assertEqual(len(wallet.coins), 1) + self.assertIsInstance(wallet.coins[0], LBC) + self.assertEqual(len(wallet.accounts), 1) account = wallet.default_account self.assertIsInstance(account, Account) @@ -91,8 +92,5 @@ class TestWalletAccount(unittest.TestCase): account.change_keys.addresses[0], 'bFpHENtqugKKHDshKFq2Mnb59Y2bx4vKgL' ) - - self.assertDictEqual( - wallet_data['accounts'][0], - account.to_json() - ) + wallet_dict['coins'] = {'lbc_mainnet': {'fee_per_name_char': 200000, 'fee_per_byte': 50}} + self.assertDictEqual(wallet_dict, wallet.to_dict()) diff --git a/lbrynet/wallet/__init__.py b/lbrynet/wallet/__init__.py index 7b8ba2a7a..b9a49d247 100644 --- a/lbrynet/wallet/__init__.py +++ b/lbrynet/wallet/__init__.py @@ -1,10 +1 @@ -_wallet_manager = None - - -def set_wallet_manager(wallet_manager): - global _wallet_manager - _wallet_manager = wallet_manager - - -def get_wallet_manager(): - return _wallet_manager +import coins diff --git a/lbrynet/wallet/account.py b/lbrynet/wallet/account.py index 12457e7a9..8046d852b 100644 --- a/lbrynet/wallet/account.py +++ b/lbrynet/wallet/account.py @@ -1,21 +1,22 @@ +import itertools +from typing import Dict, Generator from binascii import hexlify, unhexlify -from itertools import chain -from lbrynet.wallet import get_wallet_manager + +from lbrynet.wallet.basecoin import BaseCoin from lbrynet.wallet.mnemonic import Mnemonic from lbrynet.wallet.bip32 import PrivateKey, PubKey, from_extended_key_string from lbrynet.wallet.hash import double_sha256, aes_encrypt, aes_decrypt -from lbryschema.address import public_key_to_address - class KeyChain: def __init__(self, parent_key, child_keys, gap): + self.coin = parent_key.coin self.parent_key = parent_key # type: PubKey self.child_keys = child_keys self.minimum_gap = gap self.addresses = [ - public_key_to_address(key) + self.coin.public_key_to_address(key) for key in child_keys ] @@ -23,9 +24,8 @@ class KeyChain: def has_gap(self): if len(self.addresses) < self.minimum_gap: return False - ledger = get_wallet_manager().ledger for address in self.addresses[-self.minimum_gap:]: - if ledger.is_address_old(address): + if self.coin.ledger.is_address_old(address): return False return True @@ -44,71 +44,77 @@ class KeyChain: class Account: - def __init__(self, seed, encrypted, private_key, public_key, **kwargs): - self.seed = seed - self.encrypted = encrypted + def __init__(self, coin, seed, encrypted, private_key, public_key, + receiving_keys=None, receiving_gap=20, + change_keys=None, change_gap=6): + self.coin = coin # type: BaseCoin + self.seed = seed # type: str + self.encrypted = encrypted # type: bool self.private_key = private_key # type: PrivateKey self.public_key = public_key # type: PubKey - self.receiving_gap = kwargs.get('receiving_gap', 20) - self.receiving_keys = kwargs.get('receiving_keys') or \ - KeyChain(self.public_key.child(0), [], self.receiving_gap) - self.change_gap = kwargs.get('change_gap', 6) - self.change_keys = kwargs.get('change_keys') or \ - KeyChain(self.public_key.child(1), [], self.change_gap) - self.keychains = [ - self.receiving_keys, # child: 0 - self.change_keys # child: 1 - ] + self.keychains = ( + KeyChain(public_key.child(0), receiving_keys or [], receiving_gap), + KeyChain(public_key.child(1), change_keys or [], change_gap) + ) + self.receiving_keys, self.change_keys = self.keychains @classmethod - def generate(cls): + def generate(cls, coin): # type: (BaseCoin) -> Account seed = Mnemonic().make_seed() - return cls.generate_from_seed(seed) + return cls.from_seed(coin, seed) @classmethod - def generate_from_seed(cls, seed): - private_key = cls.get_private_key_from_seed(seed) + def from_seed(cls, coin, seed): # type: (BaseCoin, str) -> Account + private_key = cls.get_private_key_from_seed(coin, seed) return cls( - seed=seed, encrypted=False, + coin=coin, seed=seed, encrypted=False, private_key=private_key, - public_key=private_key.public_key, + public_key=private_key.public_key ) + @staticmethod + def get_private_key_from_seed(coin, seed): # type: (BaseCoin, str) -> PrivateKey + return PrivateKey.from_seed(coin, Mnemonic.mnemonic_to_seed(seed)) + @classmethod - def from_json(cls, json_data): - data = json_data.copy() - if not data['encrypted']: - data['private_key'] = from_extended_key_string(data['private_key']) - data['public_key'] = from_extended_key_string(data['public_key']) - data['receiving_keys'] = KeyChain( - data['public_key'].child(0), - [unhexlify(k) for k in data['receiving_keys']], - data['receiving_gap'] + def from_dict(cls, coin, d): # type: (BaseCoin, Dict) -> Account + if not d['encrypted']: + private_key = from_extended_key_string(coin, d['private_key']) + public_key = private_key.public_key + else: + private_key = d['private_key'] + public_key = from_extended_key_string(coin, d['public_key']) + return cls( + coin=coin, + seed=d['seed'], + encrypted=d['encrypted'], + private_key=private_key, + public_key=public_key, + receiving_keys=map(unhexlify, d['receiving_keys']), + receiving_gap=d['receiving_gap'], + change_keys=map(unhexlify, d['change_keys']), + change_gap=d['change_gap'] ) - data['change_keys'] = KeyChain( - data['public_key'].child(1), - [unhexlify(k) for k in data['change_keys']], - data['change_gap'] - ) - return cls(**data) - def to_json(self): + def to_dict(self): return { + 'coin': self.coin.get_id(), 'seed': self.seed, 'encrypted': self.encrypted, - 'private_key': self.private_key.extended_key_string(), + 'private_key': self.private_key if self.encrypted else + self.private_key.extended_key_string(), 'public_key': self.public_key.extended_key_string(), 'receiving_keys': [hexlify(k) for k in self.receiving_keys.child_keys], - 'receiving_gap': self.receiving_gap, + 'receiving_gap': self.receiving_keys.minimum_gap, 'change_keys': [hexlify(k) for k in self.change_keys.child_keys], - 'change_gap': self.change_gap + 'change_gap': self.change_keys.minimum_gap } def decrypt(self, password): assert self.encrypted, "Key is not encrypted." secret = double_sha256(password) self.seed = aes_decrypt(secret, self.seed) - self.private_key = from_extended_key_string(aes_decrypt(secret, self.private_key)) + self.private_key = from_extended_key_string(self.coin, aes_decrypt(secret, self.private_key)) self.encrypted = False def encrypt(self, password): @@ -118,13 +124,9 @@ class Account: self.private_key = aes_encrypt(secret, self.private_key.extended_key_string()) self.encrypted = True - @staticmethod - def get_private_key_from_seed(seed): - return PrivateKey.from_seed(Mnemonic.mnemonic_to_seed(seed)) - @property def addresses(self): - return chain(self.receiving_keys.addresses, self.change_keys.addresses) + return itertools.chain(self.receiving_keys.addresses, self.change_keys.addresses) def get_private_key_for_address(self, address): assert not self.encrypted, "Cannot get private key on encrypted wallet account." @@ -139,3 +141,47 @@ class Account: for keychain in self.keychains for address in keychain.ensure_enough_addresses() ] + + def addresses_without_history(self): + for address in self.addresses: + if not self.coin.ledger.has_address(address): + yield address + + def get_least_used_receiving_address(self, max_transactions=1000): + return self._get_least_used_address( + self.receiving_keys.addresses, + self.receiving_keys, + max_transactions + ) + + def get_least_used_change_address(self, max_transactions=100): + return self._get_least_used_address( + self.change_keys.addresses, + self.change_keys, + max_transactions + ) + + def _get_least_used_address(self, addresses, keychain, max_transactions): + ledger = self.coin.ledger + address = ledger.get_least_used_address(addresses, max_transactions) + if address: + return address + address = keychain.generate_next_address() + ledger.subscribe_history(address) + return address + + def get_unspent_utxos(self): + return [ + utxo + for address in self.addresses + for utxo in self.coin.ledger.get_unspent_outputs(address) + ] + + +class AccountsView: + + def __init__(self, accounts): + self._accounts_generator = accounts + + def __iter__(self): # type: () -> Generator[Account] + return self._accounts_generator() diff --git a/lbrynet/wallet/basecoin.py b/lbrynet/wallet/basecoin.py new file mode 100644 index 000000000..99bda1bfc --- /dev/null +++ b/lbrynet/wallet/basecoin.py @@ -0,0 +1,83 @@ +import six +from typing import Dict, Type +from .hash import hash160, double_sha256, Base58 + + +class CoinRegistry(type): + coins = {} # type: Dict[str, Type[BaseCoin]] + + def __new__(mcs, name, bases, attrs): + cls = super(CoinRegistry, mcs).__new__(mcs, name, bases, attrs) # type: Type[BaseCoin] + if not (name == 'BaseCoin' and not bases): + coin_id = cls.get_id() + assert coin_id not in mcs.coins, 'Coin with id "{}" already registered.'.format(coin_id) + mcs.coins[coin_id] = cls + assert cls.ledger_class.coin_class is None, ( + "Ledger ({}) which this coin ({}) references is already referenced by another " + "coin ({}). One to one relationship between a coin and a ledger is strictly and " + "automatically enforced. Make sure that coin_class=None in the ledger and that " + "another Coin isn't already referencing this Ledger." + ).format(cls.ledger_class.__name__, name, cls.ledger_class.coin_class.__name__) + # create back reference from ledger to the coin + cls.ledger_class.coin_class = cls + return cls + + @classmethod + def get_coin_class(mcs, coin_id): # type: (str) -> Type[BaseCoin] + return mcs.coins[coin_id] + + @classmethod + def get_ledger_class(mcs, coin_id): # type: (str) -> Type[BaseLedger] + return mcs.coins[coin_id].ledger_class + + +class BaseCoin(six.with_metaclass(CoinRegistry)): + + name = None + symbol = None + network = None + + ledger_class = None # type: Type[BaseLedger] + transaction_class = None # type: Type[BaseTransaction] + + secret_prefix = None + pubkey_address_prefix = None + script_address_prefix = None + extended_public_key_prefix = None + extended_private_key_prefix = None + + def __init__(self, ledger, fee_per_byte): + self.ledger = ledger + self.fee_per_byte = fee_per_byte + + @classmethod + def get_id(cls): + return '{}_{}'.format(cls.symbol.lower(), cls.network.lower()) + + def to_dict(self): + return {'fee_per_byte': self.fee_per_byte} + + def get_input_output_fee(self, io): + """ Fee based on size of the input / output. """ + return self.fee_per_byte * io.size + + def get_transaction_base_fee(self, tx): + """ Fee for the transaction header and all outputs; without inputs. """ + return self.fee_per_byte * tx.base_size + + def hash160_to_address(self, h160): + raw_address = self.pubkey_address_prefix + h160 + return Base58.encode(raw_address + double_sha256(raw_address)[0:4]) + + @staticmethod + def address_to_hash160(address): + bytes = Base58.decode(address) + prefix, pubkey_bytes, addr_checksum = bytes[0], bytes[1:21], bytes[21:] + return pubkey_bytes + + def public_key_to_address(self, public_key): + return self.hash160_to_address(hash160(public_key)) + + @staticmethod + def private_key_to_wif(private_key): + return b'\x1c' + private_key + b'\x01' diff --git a/lbrynet/wallet/ledger.py b/lbrynet/wallet/baseledger.py similarity index 58% rename from lbrynet/wallet/ledger.py rename to lbrynet/wallet/baseledger.py index f26e38fa3..825a2f2d9 100644 --- a/lbrynet/wallet/ledger.py +++ b/lbrynet/wallet/baseledger.py @@ -2,13 +2,17 @@ import os import logging import hashlib from binascii import hexlify +from typing import List, Dict, Type +from binascii import unhexlify from operator import itemgetter from twisted.internet import threads, defer +from lbrynet.wallet.account import Account, AccountsView +from lbrynet.wallet.basecoin import BaseCoin +from lbrynet.wallet.basetransaction import BaseTransaction, BaseInput, BaseOutput +from lbrynet.wallet.basenetwork import BaseNetwork from lbrynet.wallet.stream import StreamController, execute_serially -from lbrynet.wallet.transaction import Transaction -from lbrynet.wallet.constants import CHAINS, MAIN_CHAIN, REGTEST_CHAIN, HEADER_SIZE from lbrynet.wallet.util import hex_to_int, int_to_hex, rev_hex, hash_encode from lbrynet.wallet.hash import double_sha256, pow_hash @@ -17,43 +21,76 @@ log = logging.getLogger(__name__) class Address: - def __init__(self, address): - self.address = address - self.transactions = [] + def __init__(self, pubkey_hash): + self.pubkey_hash = pubkey_hash + self.transactions = [] # type: List[BaseTransaction] + + def __iter__(self): + return iter(self.transactions) + + def __len__(self): + return len(self.transactions) def add_transaction(self, transaction): self.transactions.append(transaction) + def get_unspent_utxos(self): + inputs, outputs, utxos = [], [], [] + for tx in self: + for txi in tx.inputs: + inputs.append((txi.output_txid, txi.output_index)) + for txo in tx.outputs: + if txo.script.is_pay_pubkey_hash and txo.script.values['pubkey_hash'] == self.pubkey_hash: + outputs.append((txo, txo.transaction.hash, txo.index)) + for output in set(outputs): + if output[1:] not in inputs: + yield output[0] -class Ledger: - def __init__(self, config=None, db=None): +class BaseLedger: + + # coin_class is automatically set by BaseCoin metaclass + # when it creates the Coin classes, there is a 1..1 relationship + # between a coin and a ledger (at the class level) but a 1..* relationship + # at instance level. Only one Ledger instance should exist per coin class, + # but many coin instances can exist linking back to the single Ledger instance. + coin_class = None # type: Type[BaseCoin] + network_class = None # type: Type[BaseNetwork] + + verify_bits_to_target = True + + def __init__(self, accounts, config=None, network=None, db=None): + self.accounts = accounts # type: AccountsView self.config = config or {} self.db = db - self.addresses = {} - self.transactions = {} - self.headers = BlockchainHeaders(self.headers_path, self.config.get('chain', MAIN_CHAIN)) + self.addresses = {} # type: Dict[str, Address] + self.transactions = {} # type: Dict[str, BaseTransaction] + self.headers = Headers(self) self._on_transaction_controller = StreamController() self.on_transaction = self._on_transaction_controller.stream + self.network = network or self.network_class(self.config) + self.network.on_header.listen(self.process_header) + self.network.on_status.listen(self.process_status) @property - def headers_path(self): - filename = 'blockchain_headers' - if self.config.get('chain', MAIN_CHAIN) != MAIN_CHAIN: - filename = '{}_headers'.format(self.config['chain']) - return os.path.join(self.config.get('wallet_path', ''), filename) + def transaction_class(self): + return self.coin_class.transaction_class + + @classmethod + def from_json(cls, json_dict): + return cls(json_dict) @defer.inlineCallbacks def load(self): txs = yield self.db.get_transactions() for tx_hash, raw, height in txs: - self.transactions[tx_hash] = Transaction(raw, height) + self.transactions[tx_hash] = self.transaction_class(raw, height) txios = yield self.db.get_transaction_inputs_and_outputs() for tx_hash, address_hash, input_output, amount, height in txios: tx = self.transactions[tx_hash] address = self.addresses.get(address_hash) if address is None: - address = self.addresses[address_hash] = Address(address_hash) + address = self.addresses[address_hash] = Address(self.coin_class.address_to_hash160(address_hash)) tx.add_txio(address, input_output, amount) address.add_transaction(tx) @@ -68,10 +105,11 @@ class Ledger: age = tx_age return age > age_limit - def add_transaction(self, address, transaction): + def add_transaction(self, address, transaction): # type: (str, BaseTransaction) -> None + if address not in self.addresses: + self.addresses[address] = Address(self.coin_class.address_to_hash160(address)) + self.addresses[address].add_transaction(transaction) self.transactions.setdefault(hexlify(transaction.id), transaction) - self.addresses.setdefault(address, []) - self.addresses[address].append(transaction) self._on_transaction_controller.add(transaction) def has_address(self, address): @@ -109,20 +147,109 @@ class Ledger: transaction_counts.sort(key=itemgetter(1)) return transaction_counts[0] + def get_unspent_outputs(self, address): + if address in self.addresses: + return list(self.addresses[address].get_unspent_utxos()) + return [] -class BlockchainHeaders: + @defer.inlineCallbacks + def start(self): + first_connection = self.network.on_connected.first + self.network.start() + yield first_connection + self.headers.touch() + yield self.update_headers() + yield self.network.subscribe_headers() + yield self.update_accounts() - def __init__(self, path, chain=MAIN_CHAIN): - self.path = path - self.chain = chain - self.max_target = CHAINS[chain]['max_target'] - self.target_timespan = CHAINS[chain]['target_timespan'] - self.genesis_bits = CHAINS[chain]['genesis_bits'] + def stop(self): + return self.network.stop() + @execute_serially + @defer.inlineCallbacks + def update_headers(self): + while True: + height_sought = len(self.headers) + headers = yield self.network.get_headers(height_sought) + log.info("received {} headers starting at {} height".format(headers['count'], height_sought)) + if headers['count'] <= 0: + break + yield self.headers.connect(height_sought, headers['hex'].decode('hex')) + + @defer.inlineCallbacks + def process_header(self, response): + header = response[0] + if self.update_headers.is_running: + return + if header['height'] == len(self.headers): + # New header from network directly connects after the last local header. + yield self.headers.connect(len(self.headers), header['hex'].decode('hex')) + elif header['height'] > len(self.headers): + # New header is several heights ahead of local, do download instead. + yield self.update_headers() + + @execute_serially + def update_accounts(self): + return defer.DeferredList([ + self.update_account(a) for a in self.accounts + ]) + + @defer.inlineCallbacks + def update_account(self, account): # type: (Account) -> defer.Defferred + # Before subscribing, download history for any addresses that don't have any, + # this avoids situation where we're getting status updates to addresses we know + # need to update anyways. Continue to get history and create more addresses until + # all missing addresses are created and history for them is fully restored. + account.ensure_enough_addresses() + addresses = list(account.addresses_without_history()) + while addresses: + yield defer.DeferredList([ + self.update_history(a) for a in addresses + ]) + addresses = account.ensure_enough_addresses() + + # By this point all of the addresses should be restored and we + # can now subscribe all of them to receive updates. + yield defer.DeferredList([ + self.subscribe_history(address) + for address in account.addresses + ]) + + @defer.inlineCallbacks + def update_history(self, address): + history = yield self.network.get_history(address) + for hash in map(itemgetter('tx_hash'), history): + transaction = self.get_transaction(hash) + if not transaction: + raw = yield self.network.get_transaction(hash) + transaction = self.transaction_class(unhexlify(raw)) + self.add_transaction(address, transaction) + + @defer.inlineCallbacks + def subscribe_history(self, address): + status = yield self.network.subscribe_address(address) + if status != self.get_status(address): + self.update_history(address) + + def process_status(self, response): + address, status = response + if status != self.get_status(address): + self.update_history(address) + + +class Headers: + + def __init__(self, ledger): + self.ledger = ledger + self._size = None self._on_change_controller = StreamController() self.on_changed = self._on_change_controller.stream - self._size = None + @property + def path(self): + wallet_path = self.ledger.config.get('wallet_path', '') + filename = '{}_headers'.format(self.ledger.coin_class.get_id()) + return os.path.join(wallet_path, filename) def touch(self): if not os.path.exists(self.path): @@ -134,13 +261,13 @@ class BlockchainHeaders: return len(self) - 1 def sync_read_length(self): - return os.path.getsize(self.path) / HEADER_SIZE + return os.path.getsize(self.path) / self.ledger.header_size def sync_read_header(self, height): if 0 <= height < len(self): with open(self.path, 'rb') as f: - f.seek(height * HEADER_SIZE) - return f.read(HEADER_SIZE) + f.seek(height * self.ledger.header_size) + return f.read(self.ledger.header_size) def __len__(self): if self._size is None: @@ -168,7 +295,7 @@ class BlockchainHeaders: previous_header = header with open(self.path, 'r+b') as f: - f.seek(start * HEADER_SIZE) + f.seek(start * self.ledger.header_size) f.write(headers) f.truncate() @@ -179,9 +306,9 @@ class BlockchainHeaders: self._on_change_controller.add(change) def _iterate_headers(self, height, headers): - assert len(headers) % HEADER_SIZE == 0 - for idx in range(len(headers) / HEADER_SIZE): - start, end = idx * HEADER_SIZE, (idx + 1) * HEADER_SIZE + assert len(headers) % self.ledger.header_size == 0 + for idx in range(len(headers) / self.ledger.header_size): + start, end = idx * self.ledger.header_size, (idx + 1) * self.ledger.header_size header = headers[start:end] yield self._deserialize(height+idx, header) @@ -239,10 +366,9 @@ class BlockchainHeaders: """ See: lbrycrd/src/lbry.cpp """ if height == 0: - return self.genesis_bits, self.max_target + return self.ledger.genesis_bits, self.ledger.max_target - # bits to target - if self.chain != REGTEST_CHAIN: + if self.ledger.verify_bits_to_target: bits = last['bits'] bitsN = (bits >> 24) & 0xff assert 0x03 <= bitsN <= 0x1f, \ @@ -252,7 +378,7 @@ class BlockchainHeaders: "Second part of bits should be in [0x8000, 0x7fffff] but it was {}".format(bitsBase) # new target - retargetTimespan = self.target_timespan + retargetTimespan = self.ledger.target_timespan nActualTimespan = last['timestamp'] - first['timestamp'] nModulatedTimespan = retargetTimespan + (nActualTimespan - retargetTimespan) // 8 @@ -267,7 +393,7 @@ class BlockchainHeaders: nModulatedTimespan = nMaxTimespan # Retarget - bnPowLimit = _ArithUint256(self.max_target) + bnPowLimit = _ArithUint256(self.ledger.max_target) bnNew = _ArithUint256.SetCompact(last['bits']) bnNew *= nModulatedTimespan bnNew //= nModulatedTimespan diff --git a/lbrynet/wallet/protocol.py b/lbrynet/wallet/basenetwork.py similarity index 97% rename from lbrynet/wallet/protocol.py rename to lbrynet/wallet/basenetwork.py index 1ddf947ed..fe97ae2f7 100644 --- a/lbrynet/wallet/protocol.py +++ b/lbrynet/wallet/basenetwork.py @@ -10,7 +10,7 @@ from twisted.protocols.basic import LineOnlyReceiver from errors import RemoteServiceException, ProtocolException from errors import TransportException -from .stream import StreamController +from lbrynet.wallet.stream import StreamController log = logging.getLogger() @@ -18,6 +18,8 @@ log = logging.getLogger() def unicode2bytes(string): if isinstance(string, six.text_type): return string.encode('iso-8859-1') + elif isinstance(string, list): + return [unicode2bytes(s) for s in string] return string @@ -125,7 +127,7 @@ class StratumClientFactory(protocol.ClientFactory): return client -class Network: +class BaseNetwork: def __init__(self, config): self.config = config diff --git a/lbrynet/wallet/script.py b/lbrynet/wallet/basescript.py similarity index 81% rename from lbrynet/wallet/script.py rename to lbrynet/wallet/basescript.py index f73037cdd..0a62d6e8f 100644 --- a/lbrynet/wallet/script.py +++ b/lbrynet/wallet/basescript.py @@ -2,8 +2,8 @@ from itertools import chain from binascii import hexlify from collections import namedtuple -from .bcd_data_stream import BCDataStream -from .util import subclass_tuple +from lbrynet.wallet.bcd_data_stream import BCDataStream +from lbrynet.wallet.util import subclass_tuple # bitcoin opcodes OP_0 = 0x00 @@ -21,11 +21,6 @@ OP_PUSHDATA4 = 0x4e OP_2DROP = 0x6d OP_DROP = 0x75 -# lbry custom opcodes -OP_CLAIM_NAME = 0xb5 -OP_SUPPORT_CLAIM = 0xb6 -OP_UPDATE_CLAIM = 0xb7 - # template matching opcodes (not real opcodes) # base class for PUSH_DATA related opcodes @@ -289,12 +284,7 @@ class Script(object): @classmethod def from_source_with_template(cls, source, template): - if template in InputScript.templates: - return InputScript(source, template_hint=template) - elif template in OutputScript.templates: - return OutputScript(source, template_hint=template) - else: - return cls(source, template_hint=template) + return cls(source, template_hint=template) def parse(self, template_hint=None): tokens = self.tokens @@ -313,7 +303,7 @@ class Script(object): self.source = self.template.generate(self.values) -class InputScript(Script): +class BaseInputScript(Script): """ Input / redeem script templates (aka scriptSig) """ __slots__ = () @@ -362,7 +352,7 @@ class InputScript(Script): }) -class OutputScript(Script): +class BaseOutputScript(Script): __slots__ = () @@ -374,48 +364,9 @@ class OutputScript(Script): OP_HASH160, PUSH_SINGLE('script_hash'), OP_EQUAL )) - CLAIM_NAME_OPCODES = ( - OP_CLAIM_NAME, PUSH_SINGLE('claim_name'), PUSH_SINGLE('claim'), - OP_2DROP, OP_DROP - ) - CLAIM_NAME_PUBKEY = Template('claim_name+pay_pubkey_hash', ( - CLAIM_NAME_OPCODES + PAY_PUBKEY_HASH.opcodes - )) - CLAIM_NAME_SCRIPT = Template('claim_name+pay_script_hash', ( - CLAIM_NAME_OPCODES + PAY_SCRIPT_HASH.opcodes - )) - - SUPPORT_CLAIM_OPCODES = ( - OP_SUPPORT_CLAIM, PUSH_SINGLE('claim_name'), PUSH_SINGLE('claim_id'), - OP_2DROP, OP_DROP - ) - SUPPORT_CLAIM_PUBKEY = Template('support_claim+pay_pubkey_hash', ( - SUPPORT_CLAIM_OPCODES + PAY_PUBKEY_HASH.opcodes - )) - SUPPORT_CLAIM_SCRIPT = Template('support_claim+pay_script_hash', ( - SUPPORT_CLAIM_OPCODES + PAY_SCRIPT_HASH.opcodes - )) - - UPDATE_CLAIM_OPCODES = ( - OP_UPDATE_CLAIM, PUSH_SINGLE('claim_name'), PUSH_SINGLE('claim_id'), PUSH_SINGLE('claim'), - OP_2DROP, OP_2DROP - ) - UPDATE_CLAIM_PUBKEY = Template('update_claim+pay_pubkey_hash', ( - UPDATE_CLAIM_OPCODES + PAY_PUBKEY_HASH.opcodes - )) - UPDATE_CLAIM_SCRIPT = Template('update_claim+pay_script_hash', ( - UPDATE_CLAIM_OPCODES + PAY_SCRIPT_HASH.opcodes - )) - templates = [ PAY_PUBKEY_HASH, PAY_SCRIPT_HASH, - CLAIM_NAME_PUBKEY, - CLAIM_NAME_SCRIPT, - SUPPORT_CLAIM_PUBKEY, - SUPPORT_CLAIM_SCRIPT, - UPDATE_CLAIM_PUBKEY, - UPDATE_CLAIM_SCRIPT ] @classmethod @@ -430,14 +381,6 @@ class OutputScript(Script): 'script_hash': script_hash }) - @classmethod - def pay_claim_name_pubkey_hash(cls, claim_name, claim, pubkey_hash): - return cls(template=cls.CLAIM_NAME_PUBKEY, values={ - 'claim_name': claim_name, - 'claim': claim, - 'pubkey_hash': pubkey_hash - }) - @property def is_pay_pubkey_hash(self): return self.template.name.endswith('pay_pubkey_hash') @@ -445,19 +388,3 @@ class OutputScript(Script): @property def is_pay_script_hash(self): return self.template.name.endswith('pay_script_hash') - - @property - def is_claim_name(self): - return self.template.name.startswith('claim_name+') - - @property - def is_support_claim(self): - return self.template.name.startswith('support_claim+') - - @property - def is_update_claim(self): - return self.template.name.startswith('update_claim+') - - @property - def is_claim_involved(self): - return self.is_claim_name or self.is_support_claim or self.is_update_claim diff --git a/lbrynet/wallet/transaction.py b/lbrynet/wallet/basetransaction.py similarity index 52% rename from lbrynet/wallet/transaction.py rename to lbrynet/wallet/basetransaction.py index 5e17e6567..129a3bee0 100644 --- a/lbrynet/wallet/transaction.py +++ b/lbrynet/wallet/basetransaction.py @@ -1,14 +1,12 @@ -import io import six import logging -from binascii import hexlify from typing import List -from lbrynet.wallet import get_wallet_manager +from lbrynet.wallet.basescript import BaseInputScript, BaseOutputScript from lbrynet.wallet.bcd_data_stream import BCDataStream -from lbrynet.wallet.hash import sha256, hash160_to_address, claim_id_hash -from lbrynet.wallet.script import InputScript, OutputScript -from lbrynet.wallet.wallet import Wallet +from lbrynet.wallet.hash import sha256 +from lbrynet.wallet.account import Account +from lbrynet.wallet.util import ReadOnlyList log = logging.getLogger() @@ -19,11 +17,6 @@ NULL_HASH = '\x00'*32 class InputOutput(object): - @property - def fee(self): - """ Fee based on size of the input / output. """ - return get_wallet_manager().fee_per_byte * self.size - @property def size(self): """ Size of this input / output in bytes. """ @@ -35,30 +28,39 @@ class InputOutput(object): raise NotImplemented -class Input(InputOutput): +class BaseInput(InputOutput): + + script_class = None NULL_SIGNATURE = '0'*72 NULL_PUBLIC_KEY = '0'*33 def __init__(self, output_or_txid_index, script, sequence=0xFFFFFFFF): - if isinstance(output_or_txid_index, Output): - self.output = output_or_txid_index # type: Output + if isinstance(output_or_txid_index, BaseOutput): + self.output = output_or_txid_index # type: BaseOutput self.output_txid = self.output.transaction.hash self.output_index = self.output.index else: - self.output = None # type: Output + self.output = None # type: BaseOutput self.output_txid, self.output_index = output_or_txid_index self.sequence = sequence self.is_coinbase = self.output_txid == NULL_HASH self.coinbase = script if self.is_coinbase else None - self.script = script if not self.is_coinbase else None # type: InputScript + self.script = script if not self.is_coinbase else None # type: BaseInputScript def link_output(self, output): assert self.output is None - assert self.output_txid == output.transaction.id + assert self.output_txid == output.transaction.hash assert self.output_index == output.index self.output = output + @classmethod + def spend(cls, output): + """ Create an input to spend the output.""" + assert output.script.is_pay_pubkey_hash, 'Attempting to spend unsupported output.' + script = cls.script_class.redeem_pubkey_hash(cls.NULL_SIGNATURE, cls.NULL_PUBLIC_KEY) + return cls(output, script) + @property def amount(self): """ Amount this input adds to the transaction. """ @@ -82,7 +84,7 @@ class Input(InputOutput): sequence = stream.read_uint32() return cls( (txid, index), - InputScript(script) if not txid == NULL_HASH else script, + cls.script_class(script) if not txid == NULL_HASH else script, sequence ) @@ -98,86 +100,48 @@ class Input(InputOutput): stream.write_string(self.script.source) stream.write_uint32(self.sequence) - def to_python_source(self): - return ( - u"InputScript(\n" - u" (output_txid=unhexlify('{}'), output_index={}),\n" - u" script=unhexlify('{}')\n" - u" # tokens: {}\n" - u")").format( - hexlify(self.output_txid), self.output_index, - hexlify(self.coinbase) if self.is_coinbase else hexlify(self.script.source), - repr(self.script.tokens) - ) +class BaseOutput(InputOutput): -class Output(InputOutput): + script_class = None - def __init__(self, transaction, index, amount, script): - self.transaction = transaction # type: Transaction - self.index = index # type: int + def __init__(self, amount, script): self.amount = amount # type: int - self.script = script # type: OutputScript + self.script = script # type: BaseOutputScript + self.transaction = None # type: BaseTransaction + self.index = None # type: int self._effective_amount = None # type: int def __lt__(self, other): return self.effective_amount < other.effective_amount - def _add_and_return(self): - self.transaction.add_outputs([self]) - return self - @classmethod - def pay_pubkey_hash(cls, transaction, index, amount, pubkey_hash): - return cls( - transaction, index, amount, - OutputScript.pay_pubkey_hash(pubkey_hash) - )._add_and_return() - - @classmethod - def pay_claim_name_pubkey_hash(cls, transaction, index, amount, claim_name, claim, pubkey_hash): - return cls( - transaction, index, amount, - OutputScript.pay_claim_name_pubkey_hash(claim_name, claim, pubkey_hash) - )._add_and_return() - - def spend(self, signature=Input.NULL_SIGNATURE, pubkey=Input.NULL_PUBLIC_KEY): - """ Create the input to spend this output.""" - assert self.script.is_pay_pubkey_hash, 'Attempting to spend unsupported output.' - script = InputScript.redeem_pubkey_hash(signature, pubkey) - return Input(self, script) + def pay_pubkey_hash(cls, amount, pubkey_hash): + return cls(amount, cls.script_class.pay_pubkey_hash(pubkey_hash)) @property def effective_amount(self): """ Amount minus fees it would take to spend this output. """ if self._effective_amount is None: - txi = self.spend() - self._effective_amount = txi.effective_amount + self._effective_amount = self.input_class.spend(self).effective_amount return self._effective_amount @classmethod - def deserialize_from(cls, stream, transaction, index): + def deserialize_from(cls, stream): return cls( - transaction=transaction, - index=index, amount=stream.read_uint64(), - script=OutputScript(stream.read_string()) + script=cls.script_class(stream.read_string()) ) def serialize_to(self, stream): stream.write_uint64(self.amount) stream.write_string(self.script.source) - def to_python_source(self): - return ( - u"OutputScript(tx, index={}, amount={},\n" - u" script=unhexlify('{}')\n" - u" # tokens: {}\n" - u")").format( - self.index, self.amount, hexlify(self.script.source), repr(self.script.tokens)) +class BaseTransaction: -class Transaction: + input_class = None + output_class = None def __init__(self, raw=None, version=1, locktime=0, height=None, is_saved=False): self._raw = raw @@ -186,8 +150,8 @@ class Transaction: self.version = version # type: int self.locktime = locktime # type: int self.height = height # type: int - self.inputs = [] # type: List[Input] - self.outputs = [] # type: List[Output] + self._inputs = [] # type: List[BaseInput] + self._outputs = [] # type: List[BaseOutput] self.is_saved = is_saved # type: bool if raw is not None: self._deserialize() @@ -211,19 +175,30 @@ class Transaction: return self._raw def _reset(self): - self._raw = None - self._hash = None self._id = None - - def get_claim_id(self, output_index): - script = self.outputs[output_index] - assert script.script.is_claim_name(), 'Not a name claim.' - return claim_id_hash(self.hash, output_index) + self._hash = None + self._raw = None @property - def is_complete(self): - s, r = self.signature_count() - return r == s + def inputs(self): # type: () -> ReadOnlyList[BaseInput] + return ReadOnlyList(self._inputs) + + @property + def outputs(self): # type: () -> ReadOnlyList[BaseOutput] + return ReadOnlyList(self._outputs) + + def add_inputs(self, inputs): + self._inputs.extend(inputs) + self._reset() + return self + + def add_outputs(self, outputs): + for txo in outputs: + txo.transaction = self + txo.index = len(self._outputs) + self._outputs.append(txo) + self._reset() + return self @property def fee(self): @@ -240,30 +215,15 @@ class Transaction: """ Size in bytes of transaction meta data and all outputs; without inputs. """ return len(self._serialize(with_inputs=False)) - @property - def base_fee(self): - """ Fee for the transaction header and all outputs; without inputs. """ - byte_fee = get_wallet_manager().fee_per_byte * self.base_size - return max(byte_fee, self.claim_name_fee) - - @property - def claim_name_fee(self): - char_fee = get_wallet_manager().fee_per_name_char - fee = 0 - for output in self.outputs: - if output.script.is_claim_name: - fee += len(output.script.values['claim_name']) * char_fee - return fee - def _serialize(self, with_inputs=True): stream = BCDataStream() stream.write_uint32(self.version) if with_inputs: - stream.write_compact_size(len(self.inputs)) - for txin in self.inputs: + stream.write_compact_size(len(self._inputs)) + for txin in self._inputs: txin.serialize_to(stream) - stream.write_compact_size(len(self.outputs)) - for txout in self.outputs: + stream.write_compact_size(len(self._outputs)) + for txout in self._outputs: txout.serialize_to(stream) stream.write_uint32(self.locktime) return stream.get_bytes() @@ -271,14 +231,14 @@ class Transaction: def _serialize_for_signature(self, signing_input): stream = BCDataStream() stream.write_uint32(self.version) - stream.write_compact_size(len(self.inputs)) - for i, txin in enumerate(self.inputs): + stream.write_compact_size(len(self._inputs)) + for i, txin in enumerate(self._inputs): if signing_input == i: txin.serialize_to(stream, txin.output.script.source) else: txin.serialize_to(stream, b'') - stream.write_compact_size(len(self.outputs)) - for txout in self.outputs: + stream.write_compact_size(len(self._outputs)) + for txout in self._outputs: txout.serialize_to(stream) stream.write_uint32(self.locktime) stream.write_uint32(1) # signature hash type: SIGHASH_ALL @@ -289,58 +249,37 @@ class Transaction: stream = BCDataStream(self._raw) self.version = stream.read_uint32() input_count = stream.read_compact_size() - self.inputs = [Input.deserialize_from(stream) for _ in range(input_count)] + self.add_inputs([ + self.input_class.deserialize_from(stream) for _ in range(input_count) + ]) output_count = stream.read_compact_size() - self.outputs = [Output.deserialize_from(stream, self, i) for i in range(output_count)] + self.add_outputs([ + self.output_class.deserialize_from(stream) for _ in range(output_count) + ]) self.locktime = stream.read_uint32() - def add_inputs(self, inputs): - self.inputs.extend(inputs) - self._reset() - - def add_outputs(self, outputs): - self.outputs.extend(outputs) - self._reset() - - def sign(self, wallet): # type: (Wallet) -> bool - for i, txi in enumerate(self.inputs): + def sign(self, account): # type: (Account) -> BaseTransaction + for i, txi in enumerate(self._inputs): txo_script = txi.output.script if txo_script.is_pay_pubkey_hash: - address = hash160_to_address(txo_script.values['pubkey_hash'], wallet.chain) - private_key = wallet.get_private_key_for_address(address) + address = account.coin.hash160_to_address(txo_script.values['pubkey_hash']) + private_key = account.get_private_key_for_address(address) tx = self._serialize_for_signature(i) txi.script.values['signature'] = private_key.sign(tx)+six.int2byte(1) txi.script.values['pubkey'] = private_key.public_key.pubkey_bytes txi.script.generate() self._reset() - return True + return self def sort(self): # See https://github.com/kristovatlas/rfc/blob/master/bips/bip-li01.mediawiki - self.inputs.sort(key=lambda i: (i['prevout_hash'], i['prevout_n'])) - self.outputs.sort(key=lambda o: (o[2], pay_script(o[0], o[1]))) + self._inputs.sort(key=lambda i: (i['prevout_hash'], i['prevout_n'])) + self._outputs.sort(key=lambda o: (o[2], pay_script(o[0], o[1]))) @property def input_sum(self): - return sum(i.amount for i in self.inputs) + return sum(i.amount for i in self._inputs) @property def output_sum(self): - return sum(o.amount for o in self.outputs) - - def to_python_source(self): - s = io.StringIO() - s.write(u'tx = Transaction(version={}, locktime={}, height={})\n'.format( - self.version, self.locktime, self.height - )) - for txi in self.inputs: - s.write(u'tx.add_input(') - s.write(txi.to_python_source()) - s.write(u')\n') - for txo in self.outputs: - s.write(u'tx.add_output(') - s.write(txo.to_python_source()) - s.write(u')\n') - s.write(u'# tx.id: unhexlify("{}")\n'.format(hexlify(self.id))) - s.write(u'# tx.raw: unhexlify("{}")\n'.format(hexlify(self.raw))) - return s.getvalue() + return sum(o.amount for o in self._outputs) diff --git a/lbrynet/wallet/bip32.py b/lbrynet/wallet/bip32.py index 3cb5082c2..861ee639f 100644 --- a/lbrynet/wallet/bip32.py +++ b/lbrynet/wallet/bip32.py @@ -10,14 +10,14 @@ import struct import hashlib -from binascii import unhexlify from six import int2byte, byte2int import ecdsa import ecdsa.ellipticcurve as EC import ecdsa.numbertheory as NT -from .hash import Base58, hmac_sha512, hash160, double_sha256, public_key_to_address +from .basecoin import BaseCoin +from .hash import Base58, hmac_sha512, hash160, double_sha256 from .util import cachedproperty, bytes_to_int, int_to_bytes @@ -30,7 +30,9 @@ class _KeyBase(object): CURVE = ecdsa.SECP256k1 - def __init__(self, chain_code, n, depth, parent): + def __init__(self, coin, chain_code, n, depth, parent): + if not isinstance(coin, BaseCoin): + raise TypeError('invalid coin') if not isinstance(chain_code, (bytes, bytearray)): raise TypeError('chain code must be raw bytes') if len(chain_code) != 32: @@ -42,6 +44,7 @@ class _KeyBase(object): if parent is not None: if not isinstance(parent, type(self)): raise TypeError('parent key has bad type') + self.coin = coin self.chain_code = chain_code self.n = n self.depth = depth @@ -83,8 +86,8 @@ class _KeyBase(object): class PubKey(_KeyBase): """ A BIP32 public key. """ - def __init__(self, pubkey, chain_code, n, depth, parent=None): - super(PubKey, self).__init__(chain_code, n, depth, parent) + def __init__(self, coin, pubkey, chain_code, n, depth, parent=None): + super(PubKey, self).__init__(coin, chain_code, n, depth, parent) if isinstance(pubkey, ecdsa.VerifyingKey): self.verifying_key = pubkey else: @@ -126,7 +129,7 @@ class PubKey(_KeyBase): @cachedproperty def address(self): """ The public key as a P2PKH address. """ - return public_key_to_address(self.pubkey_bytes, 'regtest') + return self.coin.public_key_to_address(self.pubkey_bytes) def ec_point(self): return self.verifying_key.pubkey.point @@ -150,7 +153,7 @@ class PubKey(_KeyBase): verkey = ecdsa.VerifyingKey.from_public_point(point, curve=curve) - return PubKey(verkey, R, n, self.depth + 1, self) + return PubKey(self.coin, verkey, R, n, self.depth + 1, self) def identifier(self): """ Return the key's identifier as 20 bytes. """ @@ -158,7 +161,10 @@ class PubKey(_KeyBase): def extended_key(self): """ Return a raw extended public key. """ - return self._extended_key(unhexlify("0488b21e"), self.pubkey_bytes) + return self._extended_key( + self.coin.extended_public_key_prefix, + self.pubkey_bytes + ) class LowSValueSigningKey(ecdsa.SigningKey): @@ -180,8 +186,8 @@ class PrivateKey(_KeyBase): HARDENED = 1 << 31 - def __init__(self, privkey, chain_code, n, depth, parent=None): - super(PrivateKey, self).__init__(chain_code, n, depth, parent) + def __init__(self, coin, privkey, chain_code, n, depth, parent=None): + super(PrivateKey, self).__init__(coin, chain_code, n, depth, parent) if isinstance(privkey, ecdsa.SigningKey): self.signing_key = privkey else: @@ -206,11 +212,11 @@ class PrivateKey(_KeyBase): return exponent @classmethod - def from_seed(cls, seed): + def from_seed(cls, coin, seed): # This hard-coded message string seems to be coin-independent... hmac = hmac_sha512(b'Bitcoin seed', seed) privkey, chain_code = hmac[:32], hmac[32:] - return cls(privkey, chain_code, 0, 0) + return cls(coin, privkey, chain_code, 0, 0) @cachedproperty def private_key_bytes(self): @@ -222,7 +228,7 @@ class PrivateKey(_KeyBase): """ Return the corresponding extended public key. """ verifying_key = self.signing_key.get_verifying_key() parent_pubkey = self.parent.public_key if self.parent else None - return PubKey(verifying_key, self.chain_code, self.n, self.depth, + return PubKey(self.coin, verifying_key, self.chain_code, self.n, self.depth, parent_pubkey) def ec_point(self): @@ -234,7 +240,7 @@ class PrivateKey(_KeyBase): def wif(self): """ Return the private key encoded in Wallet Import Format. """ - return b'\x1c' + self.private_key_bytes + b'\x01' + return self.coin.private_key_to_wif(self.private_key_bytes) def address(self): """ The public key as a P2PKH address. """ @@ -261,7 +267,7 @@ class PrivateKey(_KeyBase): privkey = _exponent_to_bytes(exponent) - return PrivateKey(privkey, R, n, self.depth + 1, self) + return PrivateKey(self.coin, privkey, R, n, self.depth + 1, self) def sign(self, data): """ Produce a signature for piece of data by double hashing it and signing the hash. """ @@ -275,7 +281,10 @@ class PrivateKey(_KeyBase): def extended_key(self): """Return a raw extended private key.""" - return self._extended_key(unhexlify("0488ade4"), b'\0' + self.private_key_bytes) + return self._extended_key( + self.coin.extended_private_key_prefix, + b'\0' + self.private_key_bytes + ) def _exponent_to_bytes(exponent): @@ -283,7 +292,7 @@ def _exponent_to_bytes(exponent): return (int2byte(0)*32 + int_to_bytes(exponent))[-32:] -def _from_extended_key(ekey): +def _from_extended_key(coin, ekey): """Return a PubKey or PrivateKey from an extended key raw bytes.""" if not isinstance(ekey, (bytes, bytearray)): raise TypeError('extended key must be raw bytes') @@ -295,21 +304,21 @@ def _from_extended_key(ekey): n, = struct.unpack('>I', ekey[9:13]) chain_code = ekey[13:45] - if ekey[:4] == unhexlify("0488b21e"): + if ekey[:4] == coin.extended_public_key_prefix: pubkey = ekey[45:] - key = PubKey(pubkey, chain_code, n, depth) - elif ekey[:4] == unhexlify("0488ade4"): + key = PubKey(coin, pubkey, chain_code, n, depth) + elif ekey[:4] == coin.extended_private_key_prefix: if ekey[45] is not int2byte(0): raise ValueError('invalid extended private key prefix byte') privkey = ekey[46:] - key = PrivateKey(privkey, chain_code, n, depth) + key = PrivateKey(coin, privkey, chain_code, n, depth) else: raise ValueError('version bytes unrecognised') return key -def from_extended_key_string(ekey_str): +def from_extended_key_string(coin, ekey_str): """Given an extended key string, such as xpub6BsnM1W2Y7qLMiuhi7f7dbAwQZ5Cz5gYJCRzTNainXzQXYjFwtuQXHd @@ -317,4 +326,4 @@ def from_extended_key_string(ekey_str): return a PubKey or PrivateKey. """ - return _from_extended_key(Base58.decode_check(ekey_str)) + return _from_extended_key(coin, Base58.decode_check(ekey_str)) diff --git a/lbrynet/wallet/coins/__init__.py b/lbrynet/wallet/coins/__init__.py new file mode 100644 index 000000000..243bff7de --- /dev/null +++ b/lbrynet/wallet/coins/__init__.py @@ -0,0 +1,2 @@ +from . import lbc +from . import bitcoin diff --git a/lbrynet/wallet/coins/bitcoin.py b/lbrynet/wallet/coins/bitcoin.py new file mode 100644 index 000000000..955252a14 --- /dev/null +++ b/lbrynet/wallet/coins/bitcoin.py @@ -0,0 +1,43 @@ +from six import int2byte +from binascii import unhexlify +from lbrynet.wallet.baseledger import BaseLedger +from lbrynet.wallet.basenetwork import BaseNetwork +from lbrynet.wallet.basescript import BaseInputScript, BaseOutputScript +from lbrynet.wallet.basetransaction import BaseTransaction, BaseInput, BaseOutput +from lbrynet.wallet.basecoin import BaseCoin + + +class Ledger(BaseLedger): + network_class = BaseNetwork + + +class Input(BaseInput): + script_class = BaseInputScript + + +class Output(BaseOutput): + script_class = BaseOutputScript + + +class Transaction(BaseTransaction): + input_class = BaseInput + output_class = BaseOutput + + +class BTC(BaseCoin): + name = 'Bitcoin' + symbol = 'BTC' + network = 'mainnet' + + ledger_class = Ledger + transaction_class = Transaction + + pubkey_address_prefix = int2byte(0x00) + script_address_prefix = int2byte(0x05) + extended_public_key_prefix = unhexlify('0488b21e') + extended_private_key_prefix = unhexlify('0488ade4') + + default_fee_per_byte = 50 + + def __init__(self, ledger, fee_per_byte=default_fee_per_byte): + super(BTC, self).__init__(ledger, fee_per_byte) diff --git a/lbrynet/wallet/coins/lbc/__init__.py b/lbrynet/wallet/coins/lbc/__init__.py new file mode 100644 index 000000000..d2665362f --- /dev/null +++ b/lbrynet/wallet/coins/lbc/__init__.py @@ -0,0 +1 @@ +from .coin import LBC, LBCTestNet, LBCRegTest \ No newline at end of file diff --git a/lbrynet/wallet/coins/lbc/coin.py b/lbrynet/wallet/coins/lbc/coin.py new file mode 100644 index 000000000..d1e20ab29 --- /dev/null +++ b/lbrynet/wallet/coins/lbc/coin.py @@ -0,0 +1,67 @@ +from six import int2byte +from binascii import unhexlify + +from lbrynet.wallet.basecoin import BaseCoin + +from .ledger import MainNetLedger, TestNetLedger, RegTestLedger +from .transaction import Transaction + + +class LBC(BaseCoin): + name = 'LBRY Credits' + symbol = 'LBC' + network = 'mainnet' + + ledger_class = MainNetLedger + transaction_class = Transaction + + secret_prefix = int2byte(0x1c) + pubkey_address_prefix = int2byte(0x55) + script_address_prefix = int2byte(0x7a) + extended_public_key_prefix = unhexlify('019c354f') + extended_private_key_prefix = unhexlify('019c3118') + + default_fee_per_byte = 50 + default_fee_per_name_char = 200000 + + def __init__(self, ledger, fee_per_byte=default_fee_per_byte, + fee_per_name_char=default_fee_per_name_char): + super(LBC, self).__init__(ledger, fee_per_byte) + self.fee_per_name_char = fee_per_name_char + + def to_dict(self): + coin_dict = super(LBC, self).to_dict() + coin_dict['fee_per_name_char'] = self.fee_per_name_char + return coin_dict + + def get_transaction_base_fee(self, tx): + """ Fee for the transaction header and all outputs; without inputs. """ + return max( + super(LBC, self).get_transaction_base_fee(tx), + self.get_transaction_claim_name_fee(tx) + ) + + def get_transaction_claim_name_fee(self, tx): + fee = 0 + for output in tx.outputs: + if output.script.is_claim_name: + fee += len(output.script.values['claim_name']) * self.fee_per_name_char + return fee + + +class LBCTestNet(LBC): + network = 'testnet' + ledger_class = TestNetLedger + pubkey_address_prefix = int2byte(111) + script_address_prefix = int2byte(196) + extended_public_key_prefix = unhexlify('043587cf') + extended_private_key_prefix = unhexlify('04358394') + + +class LBCRegTest(LBC): + network = 'regtest' + ledger_class = RegTestLedger + pubkey_address_prefix = int2byte(111) + script_address_prefix = int2byte(196) + extended_public_key_prefix = unhexlify('043587cf') + extended_private_key_prefix = unhexlify('04358394') diff --git a/lbrynet/wallet/coins/lbc/ledger.py b/lbrynet/wallet/coins/lbc/ledger.py new file mode 100644 index 000000000..2fd179b38 --- /dev/null +++ b/lbrynet/wallet/coins/lbc/ledger.py @@ -0,0 +1,28 @@ +from lbrynet.wallet.baseledger import BaseLedger + +from .network import Network + + +class LBCLedger(BaseLedger): + network_class = Network + header_size = 112 + max_target = 0x0000ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff + genesis_hash = '9c89283ba0f3227f6c03b70216b9f665f0118d5e0fa729cedf4fb34d6a34f463' + genesis_bits = 0x1f00ffff + target_timespan = 150 + + +class MainNetLedger(LBCLedger): + pass + + +class TestNetLedger(LBCLedger): + pass + + +class RegTestLedger(LBCLedger): + max_target = 0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff + genesis_hash = '6e3fcf1299d4ec5d79c3a4c91d624a4acf9e2e173d95a1a0504f677669687556' + genesis_bits = 0x207fffff + target_timespan = 1 + verify_bits_to_target = False diff --git a/lbrynet/wallet/coins/lbc/network.py b/lbrynet/wallet/coins/lbc/network.py new file mode 100644 index 000000000..1107f6b68 --- /dev/null +++ b/lbrynet/wallet/coins/lbc/network.py @@ -0,0 +1,5 @@ +from lbrynet.wallet.basenetwork import BaseNetwork + + +class Network(BaseNetwork): + pass diff --git a/lbrynet/wallet/coins/lbc/script.py b/lbrynet/wallet/coins/lbc/script.py new file mode 100644 index 000000000..b6b84dc67 --- /dev/null +++ b/lbrynet/wallet/coins/lbc/script.py @@ -0,0 +1,80 @@ +from lbrynet.wallet.basescript import BaseInputScript, BaseOutputScript, Template +from lbrynet.wallet.basescript import PUSH_SINGLE, OP_DROP, OP_2DROP + + +class InputScript(BaseInputScript): + pass + + +class OutputScript(BaseOutputScript): + + # lbry custom opcodes + OP_CLAIM_NAME = 0xb5 + OP_SUPPORT_CLAIM = 0xb6 + OP_UPDATE_CLAIM = 0xb7 + + CLAIM_NAME_OPCODES = ( + OP_CLAIM_NAME, PUSH_SINGLE('claim_name'), PUSH_SINGLE('claim'), + OP_2DROP, OP_DROP + ) + CLAIM_NAME_PUBKEY = Template('claim_name+pay_pubkey_hash', ( + CLAIM_NAME_OPCODES + BaseOutputScript.PAY_PUBKEY_HASH.opcodes + )) + CLAIM_NAME_SCRIPT = Template('claim_name+pay_script_hash', ( + CLAIM_NAME_OPCODES + BaseOutputScript.PAY_SCRIPT_HASH.opcodes + )) + + SUPPORT_CLAIM_OPCODES = ( + OP_SUPPORT_CLAIM, PUSH_SINGLE('claim_name'), PUSH_SINGLE('claim_id'), + OP_2DROP, OP_DROP + ) + SUPPORT_CLAIM_PUBKEY = Template('support_claim+pay_pubkey_hash', ( + SUPPORT_CLAIM_OPCODES + BaseOutputScript.PAY_PUBKEY_HASH.opcodes + )) + SUPPORT_CLAIM_SCRIPT = Template('support_claim+pay_script_hash', ( + SUPPORT_CLAIM_OPCODES + BaseOutputScript.PAY_SCRIPT_HASH.opcodes + )) + + UPDATE_CLAIM_OPCODES = ( + OP_UPDATE_CLAIM, PUSH_SINGLE('claim_name'), PUSH_SINGLE('claim_id'), PUSH_SINGLE('claim'), + OP_2DROP, OP_2DROP + ) + UPDATE_CLAIM_PUBKEY = Template('update_claim+pay_pubkey_hash', ( + UPDATE_CLAIM_OPCODES + BaseOutputScript.PAY_PUBKEY_HASH.opcodes + )) + UPDATE_CLAIM_SCRIPT = Template('update_claim+pay_script_hash', ( + UPDATE_CLAIM_OPCODES + BaseOutputScript.PAY_SCRIPT_HASH.opcodes + )) + + templates = BaseOutputScript.templates + [ + CLAIM_NAME_PUBKEY, + CLAIM_NAME_SCRIPT, + SUPPORT_CLAIM_PUBKEY, + SUPPORT_CLAIM_SCRIPT, + UPDATE_CLAIM_PUBKEY, + UPDATE_CLAIM_SCRIPT + ] + + @classmethod + def pay_claim_name_pubkey_hash(cls, claim_name, claim, pubkey_hash): + return cls(template=cls.CLAIM_NAME_PUBKEY, values={ + 'claim_name': claim_name, + 'claim': claim, + 'pubkey_hash': pubkey_hash + }) + + @property + def is_claim_name(self): + return self.template.name.startswith('claim_name+') + + @property + def is_support_claim(self): + return self.template.name.startswith('support_claim+') + + @property + def is_update_claim(self): + return self.template.name.startswith('update_claim+') + + @property + def is_claim_involved(self): + return self.is_claim_name or self.is_support_claim or self.is_update_claim diff --git a/lbrynet/wallet/coins/lbc/transaction.py b/lbrynet/wallet/coins/lbc/transaction.py new file mode 100644 index 000000000..86be1c461 --- /dev/null +++ b/lbrynet/wallet/coins/lbc/transaction.py @@ -0,0 +1,34 @@ +import struct + +from lbrynet.wallet.basetransaction import BaseTransaction, BaseInput, BaseOutput +from lbrynet.wallet.hash import hash160 + +from .script import InputScript, OutputScript + + +def claim_id_hash(txid, n): + return hash160(txid + struct.pack('>I', n)) + + +class Input(BaseInput): + script_class = InputScript + + +class Output(BaseOutput): + script_class = OutputScript + + @classmethod + def pay_claim_name_pubkey_hash(cls, amount, claim_name, claim, pubkey_hash): + script = cls.script_class.pay_claim_name_pubkey_hash(claim_name, claim, pubkey_hash) + return cls(amount, script) + + +class Transaction(BaseTransaction): + + input_class = Input + output_class = Output + + def get_claim_id(self, output_index): + output = self._outputs[output_index] + assert output.script.is_claim_name(), 'Not a name claim.' + return claim_id_hash(self.hash, output_index) diff --git a/lbrynet/wallet/constants.py b/lbrynet/wallet/constants.py index 6abaed331..40769f2f0 100644 --- a/lbrynet/wallet/constants.py +++ b/lbrynet/wallet/constants.py @@ -1,5 +1,3 @@ -from lbrynet import __version__ -LBRYUM_VERSION = __version__ PROTOCOL_VERSION = '0.10' # protocol version requested NEW_SEED_VERSION = 11 # lbryum versions >= 2.0 OLD_SEED_VERSION = 4 # lbryum versions < 2.0 @@ -9,73 +7,19 @@ SEED_PREFIX = '01' # Electrum standard wallet SEED_PREFIX_2FA = '101' # extended seed for two-factor authentication -MAXIMUM_FEE_PER_BYTE = 50 -MAXIMUM_FEE_PER_NAME_CHAR = 200000 COINBASE_MATURITY = 100 CENT = 1000000 COIN = 100*CENT -# supported types of transaction outputs -TYPE_ADDRESS = 1 -TYPE_PUBKEY = 2 -TYPE_SCRIPT = 4 -TYPE_CLAIM = 8 -TYPE_SUPPORT = 16 -TYPE_UPDATE = 32 - -# claim related constants -EXPIRATION_BLOCKS = 262974 RECOMMENDED_CLAIMTRIE_HASH_CONFIRMS = 1 NO_SIGNATURE = 'ff' NULL_HASH = '0000000000000000000000000000000000000000000000000000000000000000' -HEADER_SIZE = 112 -BLOCKS_PER_CHUNK = 96 CLAIM_ID_SIZE = 20 -HEADERS_URL = "https://s3.amazonaws.com/lbry-blockchain-headers/blockchain_headers_latest" - DEFAULT_PORTS = {'t': '50001', 's': '50002', 'h': '8081', 'g': '8082'} NODES_RETRY_INTERVAL = 60 SERVER_RETRY_INTERVAL = 10 MAX_BATCH_QUERY_SIZE = 500 proxy_modes = ['socks4', 'socks5', 'http'] - -# Chain Properties -# see: https://github.com/lbryio/lbrycrd/blob/master/src/chainparams.cpp -MAIN_CHAIN = 'main' -TESTNET_CHAIN = 'testnet' -REGTEST_CHAIN = 'regtest' -CHAINS = { - MAIN_CHAIN: { - 'pubkey_address': 0, - 'script_address': 5, - 'pubkey_address_prefix': 85, - 'script_address_prefix': 122, - 'genesis_hash': '9c89283ba0f3227f6c03b70216b9f665f0118d5e0fa729cedf4fb34d6a34f463', - 'max_target': 0x0000FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF, - 'genesis_bits': 0x1f00ffff, - 'target_timespan': 150 - }, - TESTNET_CHAIN: { - 'pubkey_address': 0, - 'script_address': 5, - 'pubkey_address_prefix': 111, - 'script_address_prefix': 196, - 'genesis_hash': '9c89283ba0f3227f6c03b70216b9f665f0118d5e0fa729cedf4fb34d6a34f463', - 'max_target': 0x0000FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF, - 'genesis_bits': 0x1f00ffff, - 'target_timespan': 150 - }, - REGTEST_CHAIN: { - 'pubkey_address': 0, - 'script_address': 5, - 'pubkey_address_prefix': 111, - 'script_address_prefix': 196, - 'genesis_hash': '6e3fcf1299d4ec5d79c3a4c91d624a4acf9e2e173d95a1a0504f677669687556', - 'max_target': 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF, - 'genesis_bits': 0x207fffff, - 'target_timespan': 1 - } -} diff --git a/lbrynet/wallet/hash.py b/lbrynet/wallet/hash.py index 5148f3d3a..c04758ec8 100644 --- a/lbrynet/wallet/hash.py +++ b/lbrynet/wallet/hash.py @@ -13,11 +13,9 @@ import aes import base64 import hashlib import hmac -import struct from binascii import hexlify, unhexlify from .util import bytes_to_int, int_to_bytes -from .constants import CHAINS, MAIN_CHAIN _sha256 = hashlib.sha256 _sha512 = hashlib.sha512 @@ -77,26 +75,6 @@ def hex_str_to_hash(x): return reversed(unhexlify(x)) -def public_key_to_address(public_key, chain=MAIN_CHAIN): - return hash160_to_address(hash160(public_key), chain) - - -def hash160_to_address(h160, chain=MAIN_CHAIN): - prefix = CHAINS[chain]['pubkey_address_prefix'] - raw_address = six.int2byte(prefix) + h160 - return Base58.encode(raw_address + double_sha256(raw_address)[0:4]) - - -def address_to_hash_160(address): - bytes = Base58.decode(address) - prefix, pubkey_bytes, addr_checksum = bytes[0], bytes[1:21], bytes[21:] - return pubkey_bytes - - -def claim_id_hash(txid, n): - return hash160(txid + struct.pack('>I', n)) - - def aes_encrypt(secret, value): return base64.b64encode(aes.encryptData(secret, value.encode('utf8'))) diff --git a/lbrynet/wallet/manager.py b/lbrynet/wallet/manager.py index f396bb08a..f8d4156b0 100644 --- a/lbrynet/wallet/manager.py +++ b/lbrynet/wallet/manager.py @@ -1,141 +1,83 @@ -import logging -from binascii import unhexlify -from operator import itemgetter +import functools +from typing import List, Dict, Type from twisted.internet import defer -from lbrynet.wallet.wallet import Wallet -from lbrynet.wallet.ledger import Ledger -from lbrynet.wallet.protocol import Network -from lbrynet.wallet.transaction import Transaction -from lbrynet.wallet.stream import execute_serially -from lbrynet.wallet.constants import MAXIMUM_FEE_PER_BYTE, MAXIMUM_FEE_PER_NAME_CHAR - -log = logging.getLogger(__name__) +from lbrynet.wallet.account import AccountsView +from lbrynet.wallet.basecoin import CoinRegistry +from lbrynet.wallet.baseledger import BaseLedger +from lbrynet.wallet.wallet import Wallet, WalletStorage class WalletManager: - def __init__(self, config=None, wallet=None, ledger=None, network=None): - self.config = config or {} - self.ledger = ledger or Ledger(self.config) - self.wallet = wallet or Wallet() - self.wallets = [self.wallet] - self.network = network or Network(self.config) - self.network.on_header.listen(self.process_header) - self.network.on_status.listen(self.process_status) + def __init__(self, wallets=None, ledgers=None): + self.wallets = wallets or [] # type: List[Wallet] + self.ledgers = ledgers or {} # type: Dict[Type[BaseLedger],BaseLedger] + self.running = False + + @classmethod + def from_config(cls, config): + wallets = [] + manager = cls(wallets) + for coin_id, ledger_config in config.get('ledgers', {}).items(): + manager.get_or_create_ledger(coin_id, ledger_config) + for wallet_path in config.get('wallets', []): + wallet_storage = WalletStorage(wallet_path) + wallet = Wallet.from_storage(wallet_storage, manager) + wallets.append(wallet) + return manager + + def get_or_create_ledger(self, coin_id, ledger_config=None): + coin_class = CoinRegistry.get_coin_class(coin_id) + ledger_class = coin_class.ledger_class + ledger = self.ledgers.get(ledger_class) + if ledger is None: + ledger = ledger_class(self.get_accounts_view(coin_class), ledger_config or {}) + self.ledgers[ledger_class] = ledger + return ledger @property - def fee_per_byte(self): - return self.config.get('fee_per_byte', MAXIMUM_FEE_PER_BYTE) - - @property - def fee_per_name_char(self): - return self.config.get('fee_per_name_char', MAXIMUM_FEE_PER_NAME_CHAR) - - @property - def addresses_without_history(self): + def default_wallet(self): for wallet in self.wallets: - for address in wallet.addresses: - if not self.ledger.has_address(address): - yield address + return wallet - def get_least_used_receiving_address(self, max_transactions=1000): - return self._get_least_used_address( - self.wallet.default_account.receiving_keys.addresses, - self.wallet.default_account.receiving_keys, - max_transactions + @property + def default_account(self): + for wallet in self.wallets: + return wallet.default_account + + def get_accounts(self, coin_class): + for wallet in self.wallets: + for account in wallet.accounts: + if account.coin.__class__ is coin_class: + yield account + + def get_accounts_view(self, coin_class): + return AccountsView( + functools.partial(self.get_accounts, coin_class) ) - def get_least_used_change_address(self, max_transactions=100): - return self._get_least_used_address( - self.wallet.default_account.change_keys.addresses, - self.wallet.default_account.change_keys, - max_transactions - ) + def create_wallet(self, path, coin_class): + storage = WalletStorage(path) + wallet = Wallet.from_storage(storage, self) + self.wallets.append(wallet) + self.create_account(wallet, coin_class) + return wallet - def _get_least_used_address(self, addresses, sequence, max_transactions): - address = self.ledger.get_least_used_address(addresses, max_transactions) - if address: - return address - address = sequence.generate_next_address() - self.subscribe_history(address) - return address + def create_account(self, wallet, coin_class): + ledger = self.get_or_create_ledger(coin_class.get_id()) + return wallet.generate_account(ledger) @defer.inlineCallbacks - def start(self): - first_connection = self.network.on_connected.first - self.network.start() - yield first_connection - self.ledger.headers.touch() - yield self.update_headers() - yield self.network.subscribe_headers() - yield self.update_wallet() - - def stop(self): - return self.network.stop() - - @execute_serially - @defer.inlineCallbacks - def update_headers(self): - while True: - height_sought = len(self.ledger.headers) - headers = yield self.network.get_headers(height_sought) - log.info("received {} headers starting at {} height".format(headers['count'], height_sought)) - if headers['count'] <= 0: - break - yield self.ledger.headers.connect(height_sought, headers['hex'].decode('hex')) - - @defer.inlineCallbacks - def process_header(self, response): - header = response[0] - if self.update_headers.is_running: - return - if header['height'] == len(self.ledger.headers): - # New header from network directly connects after the last local header. - yield self.ledger.headers.connect(len(self.ledger.headers), header['hex'].decode('hex')) - elif header['height'] > len(self.ledger.headers): - # New header is several heights ahead of local, do download instead. - yield self.update_headers() - - @execute_serially - @defer.inlineCallbacks - def update_wallet(self): - # Before subscribing, download history for any addresses that don't have any, - # this avoids situation where we're getting status updates to addresses we know - # need to update anyways. Continue to get history and create more addresses until - # all missing addresses are created and history for them is fully restored. - self.wallet.ensure_enough_addresses() - addresses = list(self.addresses_without_history) - while addresses: - yield defer.gatherResults([ - self.update_history(a) for a in addresses - ]) - addresses = self.wallet.ensure_enough_addresses() - - # By this point all of the addresses should be restored and we - # can now subscribe all of them to receive updates. - yield defer.gatherResults([ - self.subscribe_history(address) - for address in self.wallet.addresses + def start_ledgers(self): + self.running = True + yield defer.DeferredList([ + l.start() for l in self.ledgers.values() ]) @defer.inlineCallbacks - def update_history(self, address): - history = yield self.network.get_history(address) - for hash in map(itemgetter('tx_hash'), history): - transaction = self.ledger.get_transaction(hash) - if not transaction: - raw = yield self.network.get_transaction(hash) - transaction = Transaction(unhexlify(raw)) - self.ledger.add_transaction(address, transaction) - - @defer.inlineCallbacks - def subscribe_history(self, address): - status = yield self.network.subscribe_address(address) - if status != self.ledger.get_status(address): - self.update_history(address) - - def process_status(self, response): - address, status = response - if status != self.ledger.get_status(address): - self.update_history(address) + def stop_ledgers(self): + yield defer.DeferredList([ + l.stop() for l in self.ledgers.values() + ]) + self.running = False diff --git a/lbrynet/wallet/util.py b/lbrynet/wallet/util.py index dcf5ee4f6..49b552722 100644 --- a/lbrynet/wallet/util.py +++ b/lbrynet/wallet/util.py @@ -1,4 +1,17 @@ from binascii import unhexlify, hexlify +from collections import Sequence + + +class ReadOnlyList(Sequence): + + def __init__(self, lst): + self.lst = lst + + def __getitem__(self, key): + return self.lst[key] + + def __len__(self): + return len(self.lst) def subclass_tuple(name, base): @@ -17,6 +30,15 @@ class cachedproperty(object): return value +class classproperty(object): + + def __init__(self, f): + self.f = f + + def __get__(self, obj, owner): + return self.f(owner) + + def bytes_to_int(be_bytes): """ Interprets a big-endian sequence of bytes as an integer. """ return int(hexlify(be_bytes), 16) diff --git a/lbrynet/wallet/wallet.py b/lbrynet/wallet/wallet.py index 2cc7a8dee..0b1645d79 100644 --- a/lbrynet/wallet/wallet.py +++ b/lbrynet/wallet/wallet.py @@ -1,110 +1,150 @@ import stat import json import os +from typing import List, Dict from lbrynet.wallet.account import Account -from lbrynet.wallet.constants import MAIN_CHAIN +from lbrynet.wallet.basecoin import CoinRegistry, BaseCoin +from lbrynet.wallet.baseledger import BaseLedger + + +def inflate_coin(manager, coin_id, coin_dict): + # type: ('WalletManager', str, Dict) -> BaseCoin + coin_class = CoinRegistry.get_coin_class(coin_id) + ledger = manager.get_or_create_ledger(coin_id) + return coin_class(ledger, **coin_dict) class Wallet: + """ The primary role of Wallet is to encapsulate a collection + of accounts (seed/private keys) and the spending rules / settings + for the coins attached to those accounts. Wallets are represented + by physical files on the filesystem. + """ - def __init__(self, **kwargs): - self.name = kwargs.get('name', 'Wallet') - self.chain = kwargs.get('chain', MAIN_CHAIN) - self.accounts = kwargs.get('accounts') or {0: Account.generate()} + def __init__(self, name='Wallet', coins=None, accounts=None, storage=None): + self.name = name + self.coins = coins or [] # type: List[BaseCoin] + self.accounts = accounts or [] # type: List[Account] + self.storage = storage or WalletStorage() + + def get_or_create_coin(self, ledger, coin_dict=None): # type: (BaseLedger, Dict) -> BaseCoin + for coin in self.coins: + if coin.__class__ is ledger.coin_class: + return coin + coin = ledger.coin_class(ledger, **(coin_dict or {})) + self.coins.append(coin) + return coin + + def generate_account(self, ledger): # type: (BaseLedger) -> Account + coin = self.get_or_create_coin(ledger) + account = Account.generate(coin) + self.accounts.append(account) + return account @classmethod - def from_json(cls, json_data): - if 'accounts' in json_data: - json_data = json_data.copy() - json_data['accounts'] = { - a_id: Account.from_json(a) for - a_id, a in json_data['accounts'].items() - } - return cls(**json_data) + def from_storage(cls, storage, manager): # type: (WalletStorage, 'WalletManager') -> Wallet + json_dict = storage.read() - def to_json(self): + coins = {} + for coin_id, coin_dict in json_dict.get('coins', {}).items(): + coins[coin_id] = inflate_coin(manager, coin_id, coin_dict) + + accounts = [] + for account_dict in json_dict.get('accounts', []): + coin_id = account_dict['coin'] + coin = coins.get(coin_id) + if coin is None: + coin = coins[coin_id] = inflate_coin(manager, coin_id, {}) + account = Account.from_dict(coin, account_dict) + accounts.append(account) + + return cls( + name=json_dict.get('name', 'Wallet'), + coins=list(coins.values()), + accounts=accounts, + storage=storage + ) + + def to_dict(self): return { 'name': self.name, - 'chain': self.chain, - 'accounts': { - a_id: a.to_json() for - a_id, a in self.accounts.items() - } + 'coins': {c.get_id(): c.to_dict() for c in self.coins}, + 'accounts': [a.to_dict() for a in self.accounts] } + def save(self): + self.storage.write(self.to_dict()) + @property def default_account(self): - return self.accounts.get(0, None) + for account in self.accounts: + return account - @property - def addresses(self): - for account in self.accounts.values(): - for address in account.addresses: - yield address - - def ensure_enough_addresses(self): - return [ - address - for account in self.accounts.values() - for address in account.ensure_enough_addresses() - ] - - def get_private_key_for_address(self, address): - for account in self.accounts.values(): + def get_account_private_key_for_address(self, address): + for account in self.accounts: private_key = account.get_private_key_for_address(address) if private_key is not None: - return private_key + return account, private_key -class EphemeralWalletStorage(dict): +class WalletStorage: LATEST_VERSION = 2 - def save(self): - return json.dumps(self, indent=4, sort_keys=True) + DEFAULT = { + 'version': LATEST_VERSION, + 'name': 'Wallet', + 'coins': {}, + 'accounts': [] + } - def upgrade(self): + def __init__(self, path=None, default=None): + self.path = path + self._default = default or self.DEFAULT.copy() + + @property + def default(self): + return self._default.copy() + + def read(self): + if self.path and self.path.exists(self.path): + with open(self.path, "r") as f: + json_data = f.read() + json_dict = json.loads(json_data) + if json_dict.get('version') == self.LATEST_VERSION and \ + set(json_dict) == set(self._default): + return json_dict + else: + return self.upgrade(json_dict) + else: + return self.default + + @classmethod + def upgrade(cls, json_dict): + json_dict = json_dict.copy() def _rename_property(old, new): - if old in self: - old_value = self[old] - del self[old] - if new not in self: - self[new] = old_value + if old in json_dict: + json_dict[new] = json_dict[old] + del json_dict[old] - if self.get('version', 1) == 1: # upgrade from version 1 to version 2 - # TODO: `addr_history` should actually be imported into SQLStorage and removed from wallet. + version = json_dict.pop('version', -1) + + if version == 1: # upgrade from version 1 to version 2 _rename_property('addr_history', 'history') _rename_property('use_encryption', 'encrypted') _rename_property('gap_limit', 'gap_limit_for_receiving') - self['version'] = 2 - self.save() + upgraded = cls.DEFAULT + upgraded.update(json_dict) + return json_dict + def write(self, json_dict): -class PermanentWalletStorage(EphemeralWalletStorage): - - def __init__(self, *args, **kwargs): - super(PermanentWalletStorage, self).__init__(*args, **kwargs) - self.path = None - - @classmethod - def from_path(cls, path): - if os.path.exists(path): - with open(path, "r") as f: - json_data = f.read() - json_dict = json.loads(json_data) - storage = cls(**json_dict) - if 'version' in storage and storage['version'] != storage.LATEST_VERSION: - storage.upgrade() - else: - storage = cls() - storage.path = path - return storage - - def save(self): - json_data = super(PermanentWalletStorage, self).save() + json_data = json.dumps(json_dict, indent=4, sort_keys=True) + if self.path is None: + return json_data temp_path = "%s.tmp.%s" % (self.path, os.getpid()) with open(temp_path, "w") as f: @@ -116,12 +156,9 @@ class PermanentWalletStorage(EphemeralWalletStorage): mode = os.stat(self.path).st_mode else: mode = stat.S_IREAD | stat.S_IWRITE - try: os.rename(temp_path, self.path) except: os.remove(self.path) os.rename(temp_path, self.path) os.chmod(self.path, mode) - - return json_data