diff --git a/lbry/db/database.py b/lbry/db/database.py index 827a1fda9..25a5a1ede 100644 --- a/lbry/db/database.py +++ b/lbry/db/database.py @@ -288,8 +288,8 @@ 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 sum_supports(self, claim_hash, include_channel_content=False, exclude_own_supports=False) -> List[Dict]: + return await self.run(q.sum_supports, claim_hash, include_channel_content, exclude_own_supports) 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 394a67d32..19defebc8 100644 --- a/lbry/db/queries/search.py +++ b/lbry/db/queries/search.py @@ -2,7 +2,7 @@ import struct import logging from decimal import Decimal from binascii import unhexlify -from typing import Tuple, List, Optional +from typing import Tuple, List, Optional, Dict from sqlalchemy import func, case, text from sqlalchemy.future import select, Select @@ -62,30 +62,39 @@ 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]]: +def sum_supports(claim_hash, include_channel_content=False, exclude_own_supports=False) -> List[Dict]: 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) + support_join_condition = TXO.c.channel_hash == supporter.c.claim_hash + if exclude_own_supports: + support_join_condition &= TXO.c.channel_hash != claim_hash q = select( - supporter.c.claim_name.label("supporter"), + supporter.c.short_url.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) + .join(supporter, support_join_condition) ).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 + supporter.c.short_url ).order_by( - text("staked DESC") + text("staked DESC, supporter ASC") ) - return context().fetchall(q) + + result = context().fetchall(q) + total = sum([row['staked'] for row in result]) + for row in result: + row['percent'] = round(row['staked']/total*100, 4) + + return result def search_support_count(**constraints) -> int: diff --git a/lbry/service/api.py b/lbry/service/api.py index 9d9253ed7..ac55e661c 100644 --- a/lbry/service/api.py +++ b/lbry/service/api.py @@ -2579,20 +2579,22 @@ class API: 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 + exclude_own_supports: bool = False, # exclude supports signed by claim_id (i.e. self-supports) **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. + If claim_id is a channel claim: + Use --include_channel_content to include supports for content claims in the channel. + Use --exclude_own_supports to exclude supports from the channel to itself. Usage: support sum [--inculde_channel_content] {kwargs} """ - return await self.service.sum_supports(hex_str_to_hash(claim_id), include_channel_content) + return await self.service.sum_supports(hex_str_to_hash(claim_id), include_channel_content, exclude_own_supports) async def support_abandon( self, diff --git a/lbry/service/base.py b/lbry/service/base.py index 1a5d54e93..ec48fcb14 100644 --- a/lbry/service/base.py +++ b/lbry/service/base.py @@ -152,7 +152,8 @@ class Service: async def search_transactions(self, txids): raise NotImplementedError - async def sum_supports(self, claim_hash: bytes, include_channel_content=False) -> List[Dict]: + async def sum_supports(self, claim_hash: bytes, include_channel_content=False, exclude_own_supports=False) \ + -> List[Dict]: raise NotImplementedError async def announce_addresses(self, address_manager, addresses: List[str]): diff --git a/lbry/service/full_node.py b/lbry/service/full_node.py index 10c75cfa5..ddc816a32 100644 --- a/lbry/service/full_node.py +++ b/lbry/service/full_node.py @@ -69,5 +69,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) + async def sum_supports(self, claim_hash: bytes, include_channel_content=False, exclude_own_supports=False) \ + -> List[Dict]: + return await self.db.sum_supports(claim_hash, include_channel_content, exclude_own_supports) diff --git a/lbry/service/light_client.py b/lbry/service/light_client.py index 67342f1ed..fd72a511d 100644 --- a/lbry/service/light_client.py +++ b/lbry/service/light_client.py @@ -46,5 +46,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) + async def sum_supports(self, claim_hash: bytes, include_channel_content=False, exclude_own_supports=False) \ + -> List[Dict]: + return await self.client.sum_supports(claim_hash, include_channel_content, exclude_own_supports) diff --git a/tests/integration/blockchain/test_blockchain.py b/tests/integration/blockchain/test_blockchain.py index 3858f89dc..b384838db 100644 --- a/tests/integration/blockchain/test_blockchain.py +++ b/tests/integration/blockchain/test_blockchain.py @@ -945,34 +945,91 @@ class TestGeneralBlockchainSync(SyncingBlockchainTestCase): self.assertEqual(claim.claim_id, results[0].claim_id) async def test_claim_search_sum(self): + # print("DB URL: " + self.chain.ledger.conf.db_url_or_default) await self.generate(100) + # create a few channels with unique addresses channel_a = await self.get_claim(await self.create_claim(name="@A", is_channel=True)) + self.address = await self.chain.get_new_address() channel_b = await self.get_claim(await self.create_claim(name="@B", is_channel=True)) + self.address = await self.chain.get_new_address() channel_c = await self.get_claim(await self.create_claim(name="@C", is_channel=True)) + self.address = await self.chain.get_new_address() + await self.generate(1) + ch_c, ch_b, ch_a = await self.db.search_claims(order_by=['name'], claim_type="channel", limit=3) - await self.support_claim(channel_a, '10.0', sign=channel_b) - await self.support_claim(channel_a, '4.0', sign=channel_c) + # make some tips and supports from channels B and C to channel A + support_b = await self.support_claim(channel_a, '5.0', sign=channel_b) + tip_b = await self.support_claim(channel_a, '5.0', sign=channel_b, address=channel_a.get_address(self.chain.ledger)) await self.support_claim(channel_a, '2.0', sign=channel_c) + await self.support_claim(channel_a, '2.0', sign=channel_c) + tip_c = await self.support_claim(channel_a, '2.0', sign=channel_c, address=channel_a.get_address(self.chain.ledger)) await self.generate(1) + # check that supports sum correctly results = await self.db.sum_supports(channel_a.claim_hash) - self.assertEqual(results, [{'supporter': '@B', 'staked': 1000000000}, {'supporter': '@C', 'staked': 600000000}]) + self.assertEqual(results, [ + {'supporter': ch_b.meta['short_url'], 'staked': 1000000000, 'percent': 62.5}, + {'supporter': ch_c.meta['short_url'], 'staked': 600000000, 'percent': 37.5}, + ]) + # create a claim in channel A and have channel B support that claim 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) + # supports for just the channel claim should be unaffected ... results = await self.db.sum_supports(channel_a.claim_hash) - self.assertEqual(results, [{'supporter': '@B', 'staked': 1000000000}, {'supporter': '@C', 'staked': 600000000}]) + self.assertEqual(results, [ + {'supporter': ch_b.meta['short_url'], 'staked': 1000000000, 'percent': 62.5}, + {'supporter': ch_c.meta['short_url'], 'staked': 600000000, 'percent': 37.5}, + ]) + # ... but when you include supports for content in the channel, the support for claim_a is added in + results = await self.db.sum_supports(channel_a.claim_hash, include_channel_content=True) + self.assertEqual(results, [ + {'supporter': ch_b.meta['short_url'], 'staked': 1100000000, 'percent': 64.7059}, + {'supporter': ch_c.meta['short_url'], 'staked': 600000000, 'percent': 35.2941}, + ]) - results = await self.db.sum_supports(channel_a.claim_hash, True) - self.assertEqual(results, [{'supporter': '@B', 'staked': 1100000000}, {'supporter': '@C', 'staked': 600000000}]) + # check that sum_supports works as expected for a non-channel claim (with and without including channel content) + results = await self.db.sum_supports(claim_a.claim_hash, include_channel_content=False) + self.assertEqual(results, [{'supporter': ch_b.meta['short_url'], 'staked': 100000000, 'percent': 100}]) + results = await self.db.sum_supports(claim_a.claim_hash, include_channel_content=True) + self.assertEqual(results, [{'supporter': ch_b.meta['short_url'], 'staked': 100000000, 'percent': 100}]) - 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}]) + # if a support is abandoned, it stops counting + await self.abandon_support(support_b) + await self.generate(1) + results = await self.db.sum_supports(channel_a.claim_hash) + self.assertEqual(results, [ + {'supporter': ch_c.meta['short_url'], 'staked': 600000000, 'percent': 54.5455}, + {'supporter': ch_b.meta['short_url'], 'staked': 500000000, 'percent': 45.4545}, + ]) + + # but if a creator unlocks a tip, that still counts as the tipping channel's contribution + await self.abandon_support(tip_b) + await self.abandon_support(tip_c) + await self.generate(1) + results = await self.db.sum_supports(channel_a.claim_hash) + self.assertEqual(results, [ + {'supporter': ch_c.meta['short_url'], 'staked': 600000000, 'percent': 54.5455}, + {'supporter': ch_b.meta['short_url'], 'staked': 500000000, 'percent': 45.4545}, + ]) + + # a channel's own supports don't count if you exclude them + await self.support_claim(channel_a, '10.0', sign=channel_a) + await self.generate(1) + results = await self.db.sum_supports(channel_a.claim_hash, exclude_own_supports=False) + self.assertEqual(results, [ + {'supporter': ch_a.meta['short_url'], 'staked': 1000000000, 'percent': 47.6190}, + {'supporter': ch_c.meta['short_url'], 'staked': 600000000, 'percent': 28.5714}, + {'supporter': ch_b.meta['short_url'], 'staked': 500000000, 'percent': 23.8095}, + ]) + results = await self.db.sum_supports(channel_a.claim_hash, exclude_own_supports=True) + self.assertEqual(results, [ + {'supporter': ch_c.meta['short_url'], 'staked': 600000000, 'percent': 54.5455}, + {'supporter': ch_b.meta['short_url'], 'staked': 500000000, 'percent': 45.4545}, + ]) async def test_meta_fields_are_translated_to_protobuf(self): chan_ab = await self.get_claim(