diff --git a/lbry/extras/daemon/daemon.py b/lbry/extras/daemon/daemon.py index ea7a4da6f..6881889bc 100644 --- a/lbry/extras/daemon/daemon.py +++ b/lbry/extras/daemon/daemon.py @@ -28,6 +28,7 @@ from lbry.wallet import ( from lbry.wallet.dewies import dewies_to_lbc, lbc_to_dewies, dict_values_to_lbc from lbry.wallet.constants import TXO_TYPES, CLAIM_TYPE_NAMES from lbry.wallet.bip32 import PrivateKey +from lbry.crypto.base58 import Base58 from lbry import utils from lbry.conf import Config, Setting, NOT_SET @@ -1872,6 +1873,48 @@ class Daemon(metaclass=JSONRPCServerType): outputs=outputs, broadcast=broadcast ) + @requires("wallet") + async def jsonrpc_account_deposit( + self, txid, nout, redeem_script, private_key, + to_account=None, wallet_id=None, preview=False, blocking=False + ): + """ + Spend a time locked transaction into your account. + + Usage: + account_deposit + [ | --to_account=] + [--wallet_id=] [--preview] [--blocking] + + Options: + --txid= : (str) id of the transaction + --nout= : (int) output number in the transaction + --redeem_script= : (str) redeem script for output + --private_key= : (str) private key to sign transaction + --to_account= : (str) deposit to this account + --wallet_id= : (str) limit operation to specific wallet. + --preview : (bool) do not broadcast the transaction + --blocking : (bool) wait until tx has synced + + Returns: {Transaction} + """ + wallet = self.wallet_manager.get_wallet_or_default(wallet_id) + account = wallet.get_account_or_default(to_account) + other_tx = await self.wallet_manager.get_transaction(txid) + tx = await Transaction.spend_time_lock( + other_tx.outputs[nout], unhexlify(redeem_script), account + ) + pk = PrivateKey.from_bytes( + account.ledger, Base58.decode_check(private_key)[1:-1] + ) + await tx.sign([account], {pk.address: pk}) + if not preview: + await self.broadcast_or_release(tx, blocking) + self.component_manager.loop.create_task(self.analytics_manager.send_credits_sent()) + else: + await self.ledger.release_tx(tx) + return tx + @requires(WALLET_COMPONENT) def jsonrpc_account_send(self, amount, addresses, account_id=None, wallet_id=None, preview=False, blocking=False): """ diff --git a/lbry/wallet/bip32.py b/lbry/wallet/bip32.py index f9d96164e..1c2181b02 100644 --- a/lbry/wallet/bip32.py +++ b/lbry/wallet/bip32.py @@ -215,6 +215,10 @@ class PrivateKey(_KeyBase): private_key = cPrivateKey.from_int(key_int) return cls(ledger, private_key, bytes((0,)*32), 0, 0) + @classmethod + def from_bytes(cls, ledger, key_bytes) -> 'PrivateKey': + return cls(ledger, cPrivateKey(key_bytes), bytes((0,)*32), 0, 0) + @cachedproperty def private_key_bytes(self): """ Return the serialized private key (no leading zero byte). """ diff --git a/lbry/wallet/script.py b/lbry/wallet/script.py index 74e23aa25..c70511ef5 100644 --- a/lbry/wallet/script.py +++ b/lbry/wallet/script.py @@ -17,6 +17,7 @@ OP_HASH160 = 0xa9 OP_EQUALVERIFY = 0x88 OP_CHECKSIG = 0xac OP_CHECKMULTISIG = 0xae +OP_CHECKLOCKTIMEVERIFY = 0xb1 OP_EQUAL = 0x87 OP_PUSHDATA1 = 0x4c OP_PUSHDATA2 = 0x4d @@ -276,7 +277,7 @@ class Template: elif isinstance(opcode, PUSH_INTEGER): data = values[opcode.name] source.write_many(push_data( - data.to_bytes((data.bit_length() + 7) // 8, byteorder='little') + data.to_bytes((data.bit_length() + 8) // 8, byteorder='little', signed=True) )) elif isinstance(opcode, PUSH_SUBSCRIPT): data = values[opcode.name] @@ -357,19 +358,27 @@ class InputScript(Script): REDEEM_PUBKEY_HASH = Template('pubkey_hash', ( PUSH_SINGLE('signature'), PUSH_SINGLE('pubkey') )) - REDEEM_SCRIPT = Template('script', ( + MULTI_SIG_SCRIPT = Template('multi_sig', ( SMALL_INTEGER('signatures_count'), PUSH_MANY('pubkeys'), SMALL_INTEGER('pubkeys_count'), OP_CHECKMULTISIG )) - REDEEM_SCRIPT_HASH = Template('script_hash', ( - OP_0, PUSH_MANY('signatures'), PUSH_SUBSCRIPT('script', REDEEM_SCRIPT) + REDEEM_SCRIPT_HASH_MULTI_SIG = Template('script_hash+multi_sig', ( + OP_0, PUSH_MANY('signatures'), PUSH_SUBSCRIPT('script', MULTI_SIG_SCRIPT) + )) + TIME_LOCK_SCRIPT = Template('timelock', ( + PUSH_INTEGER('height'), OP_CHECKLOCKTIMEVERIFY, OP_DROP, + # rest is identical to OutputScript.PAY_PUBKEY_HASH: + OP_DUP, OP_HASH160, PUSH_SINGLE('pubkey_hash'), OP_EQUALVERIFY, OP_CHECKSIG + )) + REDEEM_SCRIPT_HASH_TIME_LOCK = Template('script_hash+timelock', ( + PUSH_SINGLE('signature'), PUSH_SINGLE('pubkey'), PUSH_SUBSCRIPT('script', TIME_LOCK_SCRIPT) )) templates = [ REDEEM_PUBKEY, REDEEM_PUBKEY_HASH, - REDEEM_SCRIPT_HASH, - REDEEM_SCRIPT + REDEEM_SCRIPT_HASH_TIME_LOCK, + REDEEM_SCRIPT_HASH_MULTI_SIG, ] @classmethod @@ -380,20 +389,38 @@ class InputScript(Script): }) @classmethod - def redeem_script_hash(cls, signatures, pubkeys): - return cls(template=cls.REDEEM_SCRIPT_HASH, values={ + def redeem_multi_sig_script_hash(cls, signatures, pubkeys): + return cls(template=cls.REDEEM_SCRIPT_HASH_MULTI_SIG, values={ 'signatures': signatures, - 'script': cls.redeem_script(signatures, pubkeys) + 'script': cls(template=cls.MULTI_SIG_SCRIPT, values={ + 'signatures_count': len(signatures), + 'pubkeys': pubkeys, + 'pubkeys_count': len(pubkeys) + }) }) @classmethod - def redeem_script(cls, signatures, pubkeys): - return cls(template=cls.REDEEM_SCRIPT, values={ - 'signatures_count': len(signatures), - 'pubkeys': pubkeys, - 'pubkeys_count': len(pubkeys) + def redeem_time_lock_script_hash(cls, signature, pubkey, height=None, pubkey_hash=None, script_source=None): + if height and pubkey_hash: + script = cls(template=cls.TIME_LOCK_SCRIPT, values={ + 'height': height, + 'pubkey_hash': pubkey_hash + }) + elif script_source: + script = cls(source=script_source, template=cls.TIME_LOCK_SCRIPT) + script.parse(script.template) + else: + raise ValueError("script_source or both height and pubkey_hash are required.") + return cls(template=cls.REDEEM_SCRIPT_HASH_TIME_LOCK, values={ + 'signature': signature, + 'pubkey': pubkey, + 'script': script }) + @property + def is_script_hash(self): + return self.template.name.startswith('script_hash+') + class OutputScript(Script): @@ -460,21 +487,6 @@ class OutputScript(Script): UPDATE_CLAIM_OPCODES + PAY_SCRIPT_HASH.opcodes )) - SELL_SCRIPT = Template('sell_script', ( - OP_VERIFY, OP_DROP, OP_DROP, OP_DROP, PUSH_INTEGER('price'), OP_PRICECHECK - )) - SELL_CLAIM = Template('sell_claim+pay_script_hash', ( - OP_SELL_CLAIM, PUSH_SINGLE('claim_id'), PUSH_SUBSCRIPT('sell_script', SELL_SCRIPT), - PUSH_SUBSCRIPT('receive_script', InputScript.REDEEM_SCRIPT), OP_2DROP, OP_2DROP - ) + PAY_SCRIPT_HASH.opcodes) - - BUY_CLAIM = Template('buy_claim+pay_script_hash', ( - OP_BUY_CLAIM, PUSH_SINGLE('sell_id'), - PUSH_SINGLE('claim_id'), PUSH_SINGLE('claim_version'), - PUSH_SINGLE('owner_pubkey_hash'), PUSH_SINGLE('negotiation_signature'), - OP_2DROP, OP_2DROP, OP_2DROP, - ) + PAY_SCRIPT_HASH.opcodes) - templates = [ PAY_PUBKEY_FULL, PAY_PUBKEY_HASH, @@ -489,8 +501,6 @@ class OutputScript(Script): SUPPORT_CLAIM_DATA_SCRIPT, UPDATE_CLAIM_PUBKEY, UPDATE_CLAIM_SCRIPT, - SELL_CLAIM, SELL_SCRIPT, - BUY_CLAIM, ] @classmethod @@ -550,30 +560,6 @@ class OutputScript(Script): 'pubkey_hash': pubkey_hash }) - @classmethod - def sell_script(cls, price): - return cls(template=cls.SELL_SCRIPT, values={ - 'price': price, - }) - - @classmethod - def sell_claim(cls, claim_id, price, signatures, pubkeys): - return cls(template=cls.SELL_CLAIM, values={ - 'claim_id': claim_id, - 'sell_script': OutputScript.sell_script(price), - 'receive_script': InputScript.redeem_script(signatures, pubkeys) - }) - - @classmethod - def buy_claim(cls, sell_id, claim_id, claim_version, owner_pubkey_hash, negotiation_signature): - return cls(template=cls.BUY_CLAIM, values={ - 'sell_id': sell_id, - 'claim_id': claim_id, - 'claim_version': claim_version, - 'owner_pubkey_hash': owner_pubkey_hash, - 'negotiation_signature': negotiation_signature, - }) - @property def is_pay_pubkey_hash(self): return self.template.name.endswith('pay_pubkey_hash') @@ -602,17 +588,6 @@ class OutputScript(Script): def is_support_claim_data(self): return self.template.name.startswith('support_claim+data+') - @property - def is_sell_claim(self): - return self.template.name.startswith('sell_claim+') - - @property - def is_buy_claim(self): - return self.template.name.startswith('buy_claim+') - @property def is_claim_involved(self): - return any(( - self.is_claim_name, self.is_support_claim, self.is_update_claim, - self.is_sell_claim, self.is_buy_claim - )) + return any((self.is_claim_name, self.is_support_claim, self.is_update_claim)) diff --git a/lbry/wallet/transaction.py b/lbry/wallet/transaction.py index 120482c55..7b81661b9 100644 --- a/lbry/wallet/transaction.py +++ b/lbry/wallet/transaction.py @@ -145,6 +145,14 @@ class Input(InputOutput): script = InputScript.redeem_pubkey_hash(cls.NULL_SIGNATURE, cls.NULL_PUBLIC_KEY) return cls(txo.ref, script) + @classmethod + def spend_time_lock(cls, txo: 'Output', script_source: bytes) -> 'Input': + """ Create an input to spend time lock script.""" + script = InputScript.redeem_time_lock_script_hash( + cls.NULL_SIGNATURE, cls.NULL_PUBLIC_KEY, script_source=script_source + ) + return cls(txo.ref, script) + @property def amount(self) -> int: """ Amount this input adds to the transaction. """ @@ -710,8 +718,11 @@ class Transaction: stream.write_compact_size(len(self._inputs)) for i, txin in enumerate(self._inputs): if signing_input == i: - assert txin.txo_ref.txo is not None - txin.serialize_to(stream, txin.txo_ref.txo.script.source) + if txin.script.is_script_hash: + txin.serialize_to(stream, txin.script.values['script'].source) + else: + assert txin.txo_ref.txo is not None + txin.serialize_to(stream, txin.txo_ref.txo.script.source) else: txin.serialize_to(stream, b'') self._serialize_outputs(stream) @@ -854,16 +865,19 @@ class Transaction: def signature_hash_type(hash_type): return hash_type - async def sign(self, funding_accounts: Iterable['Account']): + async def sign(self, funding_accounts: Iterable['Account'], extra_keys: dict = None): self._reset() ledger, wallet = self.ensure_all_have_same_ledger_and_wallet(funding_accounts) for i, txi in enumerate(self._inputs): assert txi.script is not None assert txi.txo_ref.txo is not None txo_script = txi.txo_ref.txo.script - if txo_script.is_pay_pubkey_hash: - address = ledger.hash160_to_address(txo_script.values['pubkey_hash']) - private_key = await ledger.get_private_key_for_address(wallet, address) + if txo_script.is_pay_pubkey_hash or txo_script.is_pay_script_hash: + if 'pubkey_hash' in txo_script.values: + address = ledger.hash160_to_address(txo_script.values.get('pubkey_hash', '')) + private_key = await ledger.get_private_key_for_address(wallet, address) + else: + private_key = next(iter(extra_keys.values())) assert private_key is not None, 'Cannot find private key for signing output.' tx = self._serialize_for_signature(i) txi.script.values['signature'] = \ @@ -937,6 +951,15 @@ class Transaction: data = Output.add_purchase_data(Purchase(claim_id)) return cls.create([], [payment, data], funding_accounts, change_account) + @classmethod + async def spend_time_lock(cls, time_locked_txo: Output, script: bytes, account: 'Account'): + txi = Input.spend_time_lock(time_locked_txo, script) + txi.sequence = 0xFFFFFFFE + tx = await cls.create([txi], [], [account], account, sign=False) + tx.locktime = txi.script.values['script'].values['height'] + tx._reset() + return tx + @property def my_inputs(self): for txi in self.inputs: diff --git a/tests/integration/blockchain/test_account_commands.py b/tests/integration/blockchain/test_account_commands.py index 9209fe0c6..27805c9d0 100644 --- a/tests/integration/blockchain/test_account_commands.py +++ b/tests/integration/blockchain/test_account_commands.py @@ -1,8 +1,11 @@ -from binascii import unhexlify +from binascii import hexlify, unhexlify from lbry.testcase import CommandTestCase +from lbry.wallet.script import InputScript from lbry.wallet.dewies import dewies_to_lbc from lbry.wallet.account import DeterministicChannelKeyManager +from lbry.crypto.hash import hash160 +from lbry.crypto.base58 import Base58 def extract(d, keys): @@ -289,3 +292,25 @@ class AccountManagement(CommandTestCase): self.assertTrue(channel2c.has_private_key) self.assertTrue(channel3c.has_private_key) + async def test_time_locked_transactions(self): + address = await self.account.receiving.get_or_create_usable_address() + private_key = await self.ledger.get_private_key_for_address(self.wallet, address) + + script = InputScript( + template=InputScript.TIME_LOCK_SCRIPT, + values={'height': 210, 'pubkey_hash': self.ledger.address_to_hash160(address)} + ) + script_address = self.ledger.hash160_to_script_address(hash160(script.source)) + script_source = hexlify(script.source).decode() + + await self.assertBalance(self.account, '10.0') + tx = await self.daemon.jsonrpc_account_send('4.0', script_address) + await self.confirm_tx(tx.id) + await self.generate(510) + await self.assertBalance(self.account, '5.999877') + tx = await self.daemon.jsonrpc_account_deposit( + tx.id, 0, script_source, + Base58.encode_check(self.ledger.private_key_to_wif(private_key.private_key_bytes)) + ) + await self.confirm_tx(tx.id) + await self.assertBalance(self.account, '9.9997545') diff --git a/tests/unit/wallet/test_script.py b/tests/unit/wallet/test_script.py index 7333e1133..6770002f1 100644 --- a/tests/unit/wallet/test_script.py +++ b/tests/unit/wallet/test_script.py @@ -130,12 +130,12 @@ 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 = InputScript.redeem_multi_sig_script_hash( [unhexlify(sig) for sig in sigs], [unhexlify(pubkey) for pubkey in pubkeys] ) subscript1 = src1.values['script'] - self.assertEqual(src1.template.name, 'script_hash') + self.assertEqual(src1.template.name, 'script_hash+multi_sig') self.assertListEqual([hexlify(v) for v in src1.values['signatures']], sigs) self.assertListEqual([hexlify(p) for p in subscript1.values['pubkeys']], pubkeys) self.assertEqual(subscript1.values['signatures_count'], len(sigs)) @@ -143,7 +143,7 @@ class TestRedeemScriptHash(unittest.TestCase): # now we test that it will round trip src2 = InputScript(src1.source) subscript2 = src2.values['script'] - self.assertEqual(src2.template.name, 'script_hash') + self.assertEqual(src2.template.name, 'script_hash+multi_sig') self.assertListEqual([hexlify(v) for v in src2.values['signatures']], sigs) self.assertListEqual([hexlify(p) for p in subscript2.values['pubkeys']], pubkeys) self.assertEqual(subscript2.values['signatures_count'], len(sigs)) diff --git a/tests/unit/wallet/test_transaction.py b/tests/unit/wallet/test_transaction.py index 5468b85b6..509c09ba7 100644 --- a/tests/unit/wallet/test_transaction.py +++ b/tests/unit/wallet/test_transaction.py @@ -262,6 +262,46 @@ class TestTransactionSerialization(unittest.TestCase): tx._reset() self.assertEqual(tx.raw, raw) + def test_redeem_scripthash_transaction(self): + raw = unhexlify( + "0200000001409223c2405238fdc516d4f2e8aa57637ce52d3b1ac42b26f1accdcda9697e79010000008a4" + "730440220033d5286f161da717d9d1bc3c2bc28da7636b38fc0c6aefb1e0864212f05282c02205df3ce13" + "5e79c76d44489212f77ad4e3a838562e601e6377704fa6206a6ae44f012102261773e7eebe9da80a5653d" + "865cc600362f8e7b2b598661139dd902b5b01ea101f03aaf30ab17576a914a3328f18ac1892a6667f713d" + "7020ff3437d973c888acfeffffff0180ed3e17000000001976a914353352b7ce1e3c9c05ffcd6ae97609d" + "e2999744488accdf50a00" + ) + tx = Transaction(raw) + self.assertEqual(tx.id, 'e466881128889d1cc4110627753051c22e72a81d11229a1a1337da06940bebcf') + self.assertEqual(tx.version, 2) + self.assertEqual(tx.locktime, 718285,) + self.assertEqual(len(tx.inputs), 1) + self.assertEqual(len(tx.outputs), 1) + + txin = tx.inputs[0] + self.assertEqual( + txin.txo_ref.id, + '797e69a9cdcdacf1262bc41a3b2de57c6357aae8f2d416c5fd385240c2239240:1' + ) + self.assertEqual(txin.txo_ref.position, 1) + self.assertEqual(txin.sequence, 4294967294) + self.assertIsNone(txin.coinbase) + self.assertEqual(txin.script.template.name, 'script_hash+timelock') + self.assertEqual( + hexlify(txin.script.values['signature']), + b'30440220033d5286f161da717d9d1bc3c2bc28da7636b38fc0c6aefb1e0864212f' + b'05282c02205df3ce135e79c76d44489212f77ad4e3a838562e601e6377704fa620' + b'6a6ae44f01' + ) + self.assertEqual( + hexlify(txin.script.values['pubkey']), + b'02261773e7eebe9da80a5653d865cc600362f8e7b2b598661139dd902b5b01ea10' + ) + script = txin.script.values['script'] + self.assertEqual(script.template.name, 'timelock') + self.assertEqual(script.values['height'], 717738) + self.assertEqual(hexlify(script.values['pubkey_hash']), b'a3328f18ac1892a6667f713d7020ff3437d973c8') + class TestTransactionSigning(AsyncioTestCase):