diff --git a/lbry/crypto/hash.py b/lbry/crypto/hash.py index 4f2eb728c..1cb3030ce 100644 --- a/lbry/crypto/hash.py +++ b/lbry/crypto/hash.py @@ -36,12 +36,12 @@ def hash160(x): return ripemd160(sha256(x)) -def hash_to_hex_str(x): +def hash_to_hex_str(x: bytes) -> str: """ Convert a big-endian binary hash to displayed hex string. Display form of a binary hash is reversed and converted to hex. """ - return hexlify(reversed(x)) + return hexlify(x[::-1]) -def hex_str_to_hash(x): +def hex_str_to_hash(x: str) -> bytes: """ Convert a displayed hex string to a binary hash. """ - return reversed(unhexlify(x)) + return unhexlify(x)[::-1] diff --git a/lbry/db/database.py b/lbry/db/database.py index 20b4e2fdf..827a1fda9 100644 --- a/lbry/db/database.py +++ b/lbry/db/database.py @@ -288,6 +288,9 @@ class Database: async def search_supports(self, **constraints) -> Result[Output]: return await self.fetch_result(q.search_supports, **constraints) + async def sum_supports(self, claim_hash, include_channel_content=False) -> List[Dict]: + return await self.run(q.sum_supports, claim_hash, include_channel_content) + async def resolve(self, urls, **kwargs) -> Dict[str, Output]: return await self.run(q.resolve, urls, **kwargs) diff --git a/lbry/db/queries/search.py b/lbry/db/queries/search.py index fc79b5369..394a67d32 100644 --- a/lbry/db/queries/search.py +++ b/lbry/db/queries/search.py @@ -4,7 +4,7 @@ from decimal import Decimal from binascii import unhexlify from typing import Tuple, List, Optional -from sqlalchemy import func, case +from sqlalchemy import func, case, text from sqlalchemy.future import select, Select from lbry.schema.tags import clean_tags @@ -62,6 +62,32 @@ def search_supports(**constraints) -> Tuple[List[Output], Optional[int]]: return txos, total +def sum_supports(claim_hash, include_channel_content = False) -> Tuple[List[Output], Optional[int]]: + supporter = Claim.alias("supporter") + content = Claim.alias("content") + where_condition = (content.c.claim_hash == claim_hash) + if include_channel_content: + where_condition |= (content.c.channel_hash == claim_hash) + + q = select( + supporter.c.claim_name.label("supporter"), + func.sum(TXO.c.amount).label("staked"), + ).select_from( + TXO + .join(content, TXO.c.claim_hash == content.c.claim_hash) + .join(supporter, TXO.c.channel_hash == supporter.c.claim_hash) + ).where( + where_condition & + (TXO.c.txo_type == TXO_TYPES["support"]) & + ((TXO.c.address == content.c.address) | ((TXO.c.address != content.c.address) & (TXO.c.spent_height == 0))) + ).group_by( + supporter.c.claim_name + ).order_by( + text("staked DESC") + ) + return context().fetchall(q) + + def search_support_count(**constraints) -> int: constraints.pop('offset', None) constraints.pop('limit', None) diff --git a/lbry/service/api.py b/lbry/service/api.py index dfdd5111a..9d9253ed7 100644 --- a/lbry/service/api.py +++ b/lbry/service/api.py @@ -18,6 +18,7 @@ from lbry.wallet import Wallet, Account, SingleKey, HierarchicalDeterministic from lbry.blockchain import Transaction, Output, dewies_to_lbc, dict_values_to_lbc from lbry.stream.managed_stream import ManagedStream from lbry.event import EventController, EventStream +from lbry.crypto.hash import hex_str_to_hash from .base import Service from .json_encoder import Paginated @@ -2573,6 +2574,26 @@ class API: d['total_items'] = result.total return d + async def support_sum( + self, + claim_id: str, # id of claim to calculate support stats for + include_channel_content: bool = False, # if claim_id is for a channel, include supports for + # claims in that channel + **pagination_kwargs + ) -> Paginated[Dict]: # supports grouped by channel + # TODO: add unsigned supports to the output so the numbers add up. just a left join on identity + """ + List total staked supports for a claim, grouped by the channel that signed the support. + + If claim_id is a channel claim, you can use --include_channel_content to also include supports for + content claims in the channel. + + Usage: + support sum [--inculde_channel_content] + {kwargs} + """ + return await self.service.sum_supports(hex_str_to_hash(claim_id), include_channel_content) + async def support_abandon( self, keep: str = None, # amount of lbc to keep as support diff --git a/lbry/service/base.py b/lbry/service/base.py index fc1d9e47f..1a5d54e93 100644 --- a/lbry/service/base.py +++ b/lbry/service/base.py @@ -1,7 +1,7 @@ import os import asyncio import logging -from typing import List, Optional, Tuple, NamedTuple +from typing import List, Optional, Tuple, NamedTuple, Dict from lbry.db import Database, Result from lbry.db.constants import TXO_TYPES @@ -152,6 +152,9 @@ class Service: async def search_transactions(self, txids): raise NotImplementedError + async def sum_supports(self, claim_hash: bytes, include_channel_content=False) -> List[Dict]: + raise NotImplementedError + async def announce_addresses(self, address_manager, addresses: List[str]): await self.ledger.announce_addresses(address_manager, addresses) diff --git a/lbry/service/full_node.py b/lbry/service/full_node.py index c78ee3fcc..10c75cfa5 100644 --- a/lbry/service/full_node.py +++ b/lbry/service/full_node.py @@ -1,5 +1,6 @@ import logging from binascii import hexlify, unhexlify +from typing import List, Dict from lbry.blockchain.lbrycrd import Lbrycrd from lbry.blockchain.sync import BlockchainSync @@ -67,3 +68,6 @@ class FullNode(Service): async def protobuf_resolve(self, urls, **kwargs): return await self.db.protobuf_resolve(urls, **kwargs) + + async def sum_supports(self, claim_hash: bytes, include_channel_content=False) -> List[Dict]: + return await self.db.sum_supports(claim_hash, include_channel_content) diff --git a/lbry/service/light_client.py b/lbry/service/light_client.py index ad2a7bd81..67342f1ed 100644 --- a/lbry/service/light_client.py +++ b/lbry/service/light_client.py @@ -1,4 +1,5 @@ import logging +from typing import List, Dict from lbry.conf import Config from lbry.blockchain import Ledger, Transaction @@ -44,3 +45,6 @@ class LightClient(Service): async def search_supports(self, accounts, **kwargs): pass + + async def sum_supports(self, claim_hash: bytes, include_channel_content=False) -> List[Dict]: + return await self.client.sum_supports(claim_hash, include_channel_content) diff --git a/tests/integration/blockchain/test_blockchain.py b/tests/integration/blockchain/test_blockchain.py index 91b08c74f..3858f89dc 100644 --- a/tests/integration/blockchain/test_blockchain.py +++ b/tests/integration/blockchain/test_blockchain.py @@ -191,7 +191,7 @@ class SyncingBlockchainTestCase(BasicBlockchainTestCase): async def abandon_claim(self, txid: str) -> str: return await self.chain.abandon_claim(txid, self.address) - async def support_claim(self, txo: Output, amount='0.01', sign=None) -> str: + async def support_claim(self, txo: Output, amount='0.01', sign=None, address=None) -> str: if not sign: response = await self.chain.support_claim( txo.claim_name, txo.claim_id, amount @@ -202,7 +202,7 @@ class SyncingBlockchainTestCase(BasicBlockchainTestCase): .add_outputs([ Output.pay_support_data_pubkey_hash( lbc_to_dewies(amount), txo.claim_name, txo.claim_id, Support(), - self.chain.ledger.address_to_hash160(self.address) + self.chain.ledger.address_to_hash160(address if address else self.address) ) ]) ) @@ -944,6 +944,36 @@ class TestGeneralBlockchainSync(SyncingBlockchainTestCase): results = await self.db.search_claims(effective_amount=42000000, amount_order=1, order_by=["effective_amount"]) self.assertEqual(claim.claim_id, results[0].claim_id) + async def test_claim_search_sum(self): + await self.generate(100) + + channel_a = await self.get_claim(await self.create_claim(name="@A", is_channel=True)) + channel_b = await self.get_claim(await self.create_claim(name="@B", is_channel=True)) + channel_c = await self.get_claim(await self.create_claim(name="@C", is_channel=True)) + + await self.support_claim(channel_a, '10.0', sign=channel_b) + await self.support_claim(channel_a, '4.0', sign=channel_c) + await self.support_claim(channel_a, '2.0', sign=channel_c) + await self.generate(1) + + results = await self.db.sum_supports(channel_a.claim_hash) + self.assertEqual(results, [{'supporter': '@B', 'staked': 1000000000}, {'supporter': '@C', 'staked': 600000000}]) + + claim_a = await self.get_claim(await self.create_claim(name="bob", amount='2.0', sign=channel_a)) + await self.support_claim(claim_a, '1.0', sign=channel_b) + await self.generate(1) + + results = await self.db.sum_supports(channel_a.claim_hash) + self.assertEqual(results, [{'supporter': '@B', 'staked': 1000000000}, {'supporter': '@C', 'staked': 600000000}]) + + results = await self.db.sum_supports(channel_a.claim_hash, True) + self.assertEqual(results, [{'supporter': '@B', 'staked': 1100000000}, {'supporter': '@C', 'staked': 600000000}]) + + results = await self.db.sum_supports(claim_a.claim_hash, False) + self.assertEqual(results, [{'supporter': '@B', 'staked': 100000000}]) + results = await self.db.sum_supports(claim_a.claim_hash, True) + self.assertEqual(results, [{'supporter': '@B', 'staked': 100000000}]) + async def test_meta_fields_are_translated_to_protobuf(self): chan_ab = await self.get_claim( await self.create_claim(claim_id_startswith='ab', is_channel=True))