diff --git a/lbry/extras/daemon/daemon.py b/lbry/extras/daemon/daemon.py index 43b702162..a2d3ebfcc 100644 --- a/lbry/extras/daemon/daemon.py +++ b/lbry/extras/daemon/daemon.py @@ -3980,7 +3980,9 @@ class Daemon(metaclass=JSONRPCServerType): @requires(WALLET_COMPONENT) async def jsonrpc_support_create( - self, claim_id, amount, tip=False, account_id=None, wallet_id=None, funding_account_ids=None, + self, claim_id, amount, tip=False, + channel_id=None, channel_name=None, channel_account_id=None, + account_id=None, wallet_id=None, funding_account_ids=None, preview=False, blocking=False): """ Create a support or a tip for name claim. @@ -3988,23 +3990,30 @@ class Daemon(metaclass=JSONRPCServerType): Usage: support_create ( | --claim_id=) ( | --amount=) [--tip] [--account_id=] [--wallet_id=] + [--channel_id= | --channel_name=] + [--channel_account_id=...] [--preview] [--blocking] [--funding_account_ids=...] Options: - --claim_id= : (str) claim_id of the claim to support - --amount= : (decimal) amount of support - --tip : (bool) send support to claim owner, default: false. - --account_id= : (str) account to use for holding the transaction - --wallet_id= : (str) restrict operation to specific wallet + --claim_id= : (str) claim_id of the claim to support + --amount= : (decimal) amount of support + --tip : (bool) send support to claim owner, default: false. + --channel_id= : (str) claim id of the supporters identity channel + --channel_name= : (str) name of the supporters identity channel + --channel_account_id=: (str) one or more account ids for accounts to look in + for channel certificates, defaults to all accounts. + --account_id= : (str) account to use for holding the transaction + --wallet_id= : (str) restrict operation to specific wallet --funding_account_ids=: (list) ids of accounts to fund this transaction - --preview : (bool) do not broadcast the transaction - --blocking : (bool) wait until transaction is in mempool + --preview : (bool) do not broadcast the transaction + --blocking : (bool) wait until transaction is in mempool Returns: {Transaction} """ wallet = self.wallet_manager.get_wallet_or_default(wallet_id) assert not wallet.is_locked, "Cannot spend funds with locked wallet, unlock first." funding_accounts = wallet.get_accounts_or_all(funding_account_ids) + channel = await self.get_channel_or_none(wallet, channel_account_id, channel_id, channel_name, for_signing=True) amount = self.get_dewies_or_error("amount", amount) claim = await self.ledger.get_claim_by_claim_id(wallet.accounts, claim_id) claim_address = claim.get_address(self.ledger) @@ -4013,8 +4022,13 @@ class Daemon(metaclass=JSONRPCServerType): claim_address = await account.receiving.get_or_create_usable_address() tx = await Transaction.support( - claim.claim_name, claim_id, amount, claim_address, funding_accounts, funding_accounts[0] + claim.claim_name, claim_id, amount, claim_address, funding_accounts, funding_accounts[0], channel ) + new_txo = tx.outputs[0] + + if channel: + new_txo.sign(channel) + await tx.sign(funding_accounts) if not preview: await self.broadcast_or_release(tx, blocking) diff --git a/lbry/extras/daemon/json_response_encoder.py b/lbry/extras/daemon/json_response_encoder.py index 99d487cd2..b55019614 100644 --- a/lbry/extras/daemon/json_response_encoder.py +++ b/lbry/extras/daemon/json_response_encoder.py @@ -7,6 +7,7 @@ from json import JSONEncoder from google.protobuf.message import DecodeError from lbry.schema.claim import Claim +from lbry.schema.support import Support from lbry.torrent.torrent_manager import TorrentSource from lbry.wallet import Wallet, Ledger, Account, Transaction, Output from lbry.wallet.bip32 import PubKey @@ -135,6 +136,8 @@ class JSONResponseEncoder(JSONEncoder): return self.encode_output(obj) if isinstance(obj, Claim): return self.encode_claim(obj) + if isinstance(obj, Support): + return obj.to_dict() if isinstance(obj, PubKey): return obj.extended_key_string() if isinstance(obj, datetime): @@ -220,25 +223,28 @@ class JSONResponseEncoder(JSONEncoder): output['claims'] = [self.encode_output(o) for o in txo.claims] if txo.reposted_claim is not None: output['reposted_claim'] = self.encode_output(txo.reposted_claim) - if txo.script.is_claim_name or txo.script.is_update_claim: - try: - output['value'] = txo.claim + if txo.script.is_claim_name or txo.script.is_update_claim or txo.script.is_support_claim_data: + try: + output['value'] = txo.signable + if self.include_protobuf: + output['protobuf'] = hexlify(txo.signable.to_bytes()) + if txo.purchase_receipt is not None: + output['purchase_receipt'] = self.encode_output(txo.purchase_receipt) + if txo.script.is_claim_name or txo.script.is_update_claim: output['value_type'] = txo.claim.claim_type - if self.include_protobuf: - output['protobuf'] = hexlify(txo.claim.to_bytes()) - if txo.purchase_receipt is not None: - output['purchase_receipt'] = self.encode_output(txo.purchase_receipt) if txo.claim.is_channel: output['has_signing_key'] = txo.has_private_key - if check_signature and txo.claim.is_signed: - if txo.channel is not None: - output['signing_channel'] = self.encode_output(txo.channel) - output['is_channel_signature_valid'] = txo.is_signed_by(txo.channel, self.ledger) - else: - output['signing_channel'] = {'channel_id': txo.claim.signing_channel_id} - output['is_channel_signature_valid'] = False - except DecodeError: - pass + elif txo.script.is_support_claim_data: + output['value_type'] = 'emoji' + if check_signature and txo.signable.is_signed: + if txo.channel is not None: + output['signing_channel'] = self.encode_output(txo.channel) + output['is_channel_signature_valid'] = txo.is_signed_by(txo.channel, self.ledger) + else: + output['signing_channel'] = {'channel_id': txo.signable.signing_channel_id} + output['is_channel_signature_valid'] = False + except DecodeError: + pass return output def encode_claim_meta(self, meta): diff --git a/lbry/schema/claim.py b/lbry/schema/claim.py index 6a81ff133..9b256258f 100644 --- a/lbry/schema/claim.py +++ b/lbry/schema/claim.py @@ -30,14 +30,10 @@ class Claim(Signable): COLLECTION = 'collection' REPOST = 'repost' - __slots__ = 'version', + __slots__ = () message_class = ClaimMessage - def __init__(self, message=None): - super().__init__(message) - self.version = 2 - @property def claim_type(self) -> str: return self.message.WhichOneof('type') diff --git a/lbry/schema/support.py b/lbry/schema/support.py index 00ae226b8..4e5e1ba6e 100644 --- a/lbry/schema/support.py +++ b/lbry/schema/support.py @@ -1,6 +1,15 @@ from lbry.schema.base import Signable +from lbry.schema.types.v2.support_pb2 import Support as SupportMessage class Support(Signable): __slots__ = () - message_class = None # TODO: add support protobufs + message_class = SupportMessage + + @property + def emoji(self) -> str: + return self.message.emoji + + @emoji.setter + def emoji(self, emoji: str): + self.message.emoji = emoji diff --git a/lbry/wallet/script.py b/lbry/wallet/script.py index 393852619..c8e4c0ab6 100644 --- a/lbry/wallet/script.py +++ b/lbry/wallet/script.py @@ -438,6 +438,17 @@ class OutputScript(Script): SUPPORT_CLAIM_OPCODES + PAY_SCRIPT_HASH.opcodes )) + SUPPORT_CLAIM_DATA_OPCODES = ( + OP_SUPPORT_CLAIM, PUSH_SINGLE('claim_name'), PUSH_SINGLE('claim_id'), PUSH_SINGLE('support'), + OP_2DROP, OP_2DROP + ) + SUPPORT_CLAIM_DATA_PUBKEY = Template('support_claim+data+pay_pubkey_hash', ( + SUPPORT_CLAIM_DATA_OPCODES + PAY_PUBKEY_HASH.opcodes + )) + SUPPORT_CLAIM_DATA_SCRIPT = Template('support_claim+data+pay_script_hash', ( + SUPPORT_CLAIM_DATA_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 @@ -474,6 +485,8 @@ class OutputScript(Script): CLAIM_NAME_SCRIPT, SUPPORT_CLAIM_PUBKEY, SUPPORT_CLAIM_SCRIPT, + SUPPORT_CLAIM_DATA_PUBKEY, + SUPPORT_CLAIM_DATA_SCRIPT, UPDATE_CLAIM_PUBKEY, UPDATE_CLAIM_SCRIPT, SELL_CLAIM, SELL_SCRIPT, @@ -527,6 +540,16 @@ class OutputScript(Script): 'pubkey_hash': pubkey_hash }) + @classmethod + def pay_support_data_pubkey_hash( + cls, claim_name: bytes, claim_id: bytes, support, pubkey_hash: bytes): + return cls(template=cls.SUPPORT_CLAIM_DATA_PUBKEY, values={ + 'claim_name': claim_name, + 'claim_id': claim_id, + 'support': support, + 'pubkey_hash': pubkey_hash + }) + @classmethod def sell_script(cls, price): return cls(template=cls.SELL_SCRIPT, values={ @@ -575,6 +598,10 @@ class OutputScript(Script): def is_support_claim(self): return self.template.name.startswith('support_claim+') + @property + 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+') diff --git a/lbry/wallet/transaction.py b/lbry/wallet/transaction.py index 0f32ffdd3..d5a60c354 100644 --- a/lbry/wallet/transaction.py +++ b/lbry/wallet/transaction.py @@ -19,7 +19,9 @@ from lbry.crypto.hash import hash160, sha256 from lbry.crypto.base58 import Base58 from lbry.schema.url import normalize_name from lbry.schema.claim import Claim +from lbry.schema.base import Signable from lbry.schema.purchase import Purchase +from lbry.schema.support import Support from .script import InputScript, OutputScript from .constants import COIN, NULL_HASH32 @@ -211,7 +213,7 @@ class Output(InputOutput): 'amount', 'script', 'is_internal_transfer', 'is_spent', 'is_my_output', 'is_my_input', 'channel', 'private_key', 'meta', 'sent_supports', 'sent_tips', 'received_tips', 'purchase', 'purchased_claim', 'purchase_receipt', - 'reposted_claim', 'claims', + 'reposted_claim', 'claims', '_signable' ) def __init__(self, amount: int, script: OutputScript, @@ -239,6 +241,7 @@ class Output(InputOutput): self.purchase_receipt: 'Output' = None # txo representing purchase receipt for this claim self.reposted_claim: 'Output' = None # txo representing claim being reposted self.claims: List['Output'] = None # resolved claims for collection + self._signable: Optional[Signable] = None self.meta = {} def update_annotations(self, annotated: 'Output'): @@ -312,6 +315,10 @@ class Output(InputOutput): def is_support(self) -> bool: return self.script.is_support_claim + @property + def is_support_data(self) -> bool: + return self.script.is_support_claim_data + @property def claim_hash(self) -> bytes: if self.script.is_claim_name: @@ -347,9 +354,33 @@ class Output(InputOutput): def can_decode_claim(self): try: return self.claim - except: # pylint: disable=bare-except + except Exception: return False + @property + def support(self) -> Support: + if self.is_support_data: + if not isinstance(self.script.values['support'], Support): + self.script.values['support'] = Support.from_bytes(self.script.values['support']) + return self.script.values['support'] + raise ValueError('Only supports with data can be represented as Supports.') + + @property + def can_decode_support(self): + try: + return self.support + except Exception: + return False + + @property + def signable(self) -> Signable: + if self._signable is None: + if self.is_claim: + self._signable = self.claim + elif self.is_support_data: + self._signable = self.support + return self._signable + @property def permanent_url(self) -> str: if self.script.is_claim_involved: @@ -361,22 +392,22 @@ class Output(InputOutput): return self.private_key is not None def get_signature_digest(self, ledger): - if self.claim.unsigned_payload: + if self.signable.unsigned_payload: pieces = [ Base58.decode(self.get_address(ledger)), - self.claim.unsigned_payload, - self.claim.signing_channel_hash[::-1] + self.signable.unsigned_payload, + self.signable.signing_channel_hash[::-1] ] else: pieces = [ self.tx_ref.tx.inputs[0].txo_ref.hash, - self.claim.signing_channel_hash, - self.claim.to_message_bytes() + self.signable.signing_channel_hash, + self.signable.to_message_bytes() ] return sha256(b''.join(pieces)) def get_encoded_signature(self): - signature = hexlify(self.claim.signature) + signature = hexlify(self.signable.signature) r = int(signature[:int(len(signature)/2)], 16) s = int(signature[int(len(signature)/2):], 16) return ecdsa.util.sigencode_der(r, s, len(signature)*4) @@ -400,18 +431,18 @@ class Output(InputOutput): def sign(self, channel: 'Output', first_input_id=None): self.channel = channel - self.claim.signing_channel_hash = channel.claim_hash + self.signable.signing_channel_hash = channel.claim_hash digest = sha256(b''.join([ first_input_id or self.tx_ref.tx.inputs[0].txo_ref.hash, - self.claim.signing_channel_hash, - self.claim.to_message_bytes() + self.signable.signing_channel_hash, + self.signable.to_message_bytes() ])) - self.claim.signature = channel.private_key.sign_digest_deterministic(digest, hashfunc=hashlib.sha256) + self.signable.signature = channel.private_key.sign_digest_deterministic(digest, hashfunc=hashlib.sha256) self.script.generate() def clear_signature(self): self.channel = None - self.claim.clear_signature() + self.signable.clear_signature() async def generate_channel_private_key(self): self.private_key = await asyncio.get_event_loop().run_in_executor( @@ -446,6 +477,14 @@ class Output(InputOutput): ) return cls(amount, script) + @classmethod + def pay_support_data_pubkey_hash( + cls, amount: int, claim_name: str, claim_id: str, support: Support, pubkey_hash: bytes) -> 'Output': + script = OutputScript.pay_support_data_pubkey_hash( + claim_name.encode(), unhexlify(claim_id)[::-1], support, pubkey_hash + ) + return cls(amount, script) + @classmethod def add_purchase_data(cls, purchase: Purchase) -> 'Output': script = OutputScript.return_data(purchase) @@ -860,12 +899,20 @@ class Transaction: @classmethod def support(cls, claim_name: str, claim_id: str, amount: int, holding_address: str, - funding_accounts: List['Account'], change_account: 'Account'): + funding_accounts: List['Account'], change_account: 'Account', signing_channel: Output = None): ledger, _ = cls.ensure_all_have_same_ledger_and_wallet(funding_accounts, change_account) - support_output = Output.pay_support_pubkey_hash( - amount, claim_name, claim_id, ledger.address_to_hash160(holding_address) - ) - return cls.create([], [support_output], funding_accounts, change_account) + if signing_channel is not None: + support = Support() + support.emoji = '👍' + support_output = Output.pay_support_data_pubkey_hash( + amount, claim_name, claim_id, support, ledger.address_to_hash160(holding_address) + ) + support_output.sign(signing_channel, b'placeholder txid:nout') + else: + support_output = Output.pay_support_pubkey_hash( + amount, claim_name, claim_id, ledger.address_to_hash160(holding_address) + ) + return cls.create([], [support_output], funding_accounts, change_account, sign=False) @classmethod def purchase(cls, claim_id: str, amount: int, merchant_address: bytes, diff --git a/tests/integration/blockchain/test_claim_commands.py b/tests/integration/blockchain/test_claim_commands.py index 4873e0cde..765e77d5f 100644 --- a/tests/integration/blockchain/test_claim_commands.py +++ b/tests/integration/blockchain/test_claim_commands.py @@ -1886,6 +1886,12 @@ class SupportCommands(CommandTestCase): self.assertTrue(txs[1]['support_info'][0]['is_tip']) self.assertTrue(txs[1]['support_info'][0]['is_spent']) + async def test_signed_supports(self): + channel_id = self.get_claim_id(await self.channel_create()) + stream_id = self.get_claim_id(await self.stream_create()) + tx = await self.support_create(stream_id, '0.3', channel_id=channel_id) + self.assertTrue(tx['outputs'][0]['is_channel_signature_valid']) + class CollectionCommands(CommandTestCase):