From fde0d11d2d68163bc647ac00358276cef1a7e972 Mon Sep 17 00:00:00 2001 From: Howo Date: Fri, 19 Dec 2025 18:21:11 -0500 Subject: [PATCH 1/9] Add community required beneficiaries feature with MUTED_BENEFICIARY type Implements a new community setting that allows communities to enforce required beneficiaries on top-level posts. Posts that don't meet the requirements are automatically muted with a new mute type. Changes: - Add MUTED_BENEFICIARY (5) mute type to muted_reasons_operations.sql - Create validate_required_beneficiaries() SQL function in hive_post_operations.sql that handles all validation logic and muting - Add required_beneficiaries validation in community.py settings parser - Update comment_options_op to call SQL validation function and send notifications - Add MUTED_BENEFICIARY notification message in comment_op Key features: - Validates only top-level posts (depth = 0) in communities - Strict validation: posts must have exact percentage or higher - Only applies to new posts during sync, no retroactive processing - Stored in JSONB settings field (no schema changes) - All validation logic in SQL for minimal performance impact --- hive/db/sql_scripts/hive_post_operations.sql | 120 ++++++++++++++++++ .../utilities/muted_reasons_operations.sql | 3 +- hive/indexer/blocks.py | 2 +- hive/indexer/community.py | 29 +++++ hive/indexer/posts.py | 31 ++++- 5 files changed, 181 insertions(+), 4 deletions(-) diff --git a/hive/db/sql_scripts/hive_post_operations.sql b/hive/db/sql_scripts/hive_post_operations.sql index ea1523954..a941add05 100644 --- a/hive/db/sql_scripts/hive_post_operations.sql +++ b/hive/db/sql_scripts/hive_post_operations.sql @@ -520,3 +520,123 @@ BEGIN END $function$ ; + +DROP FUNCTION IF EXISTS hivemind_app.validate_required_beneficiaries; +CREATE OR REPLACE FUNCTION hivemind_app.validate_required_beneficiaries( + _author hivemind_app.hive_accounts.name%TYPE, + _permlink hivemind_app.hive_permlink_data.permlink%TYPE, + _beneficiaries JSONB +) RETURNS TABLE (should_mute BOOLEAN, error_message TEXT, author_id INTEGER, community_id INTEGER) +LANGUAGE plpgsql +AS $$ +DECLARE + _post_id INTEGER; + _post_depth INTEGER; + _post_community_id INTEGER; + _post_author_id INTEGER; + _post_is_muted BOOLEAN; + _post_muted_reasons INTEGER; + _community_settings JSONB; + _required_beneficiaries JSONB; + _required_ben JSONB; + _required_account TEXT; + _required_weight INTEGER; + _post_ben JSONB; + _found_weight INTEGER; + _validation_failed BOOLEAN := FALSE; + _error_parts TEXT[] := ARRAY[]::TEXT[]; + _existing_muted_reasons INTEGER[] := ARRAY[]::INTEGER[]; + _new_muted_reasons INTEGER; +BEGIN + -- Get post info + SELECT hp.id, hp.depth, hp.community_id, hp.author_id, hp.is_muted, hp.muted_reasons + INTO _post_id, _post_depth, _post_community_id, _post_author_id, _post_is_muted, _post_muted_reasons + FROM hivemind_app.hive_posts hp + WHERE hp.author_id = (SELECT id FROM hivemind_app.hive_accounts WHERE name = _author) + AND hp.permlink_id = (SELECT id FROM hivemind_app.hive_permlink_data WHERE permlink = _permlink) + AND hp.counter_deleted = 0 + LIMIT 1; + + -- Return early if post doesn't exist, is a comment, or not in a community + IF _post_id IS NULL OR _post_depth != 0 OR _post_community_id IS NULL THEN + RETURN QUERY SELECT FALSE, ''::TEXT, NULL::INTEGER, NULL::INTEGER; + RETURN; + END IF; + + -- Get community settings + SELECT settings INTO _community_settings + FROM hivemind_app.hive_communities + WHERE id = _post_community_id; + + -- Return early if no settings or no required_beneficiaries + IF _community_settings IS NULL OR NOT (_community_settings ? 'required_beneficiaries') THEN + RETURN QUERY SELECT FALSE, ''::TEXT, NULL::INTEGER, NULL::INTEGER; + RETURN; + END IF; + + _required_beneficiaries := _community_settings->'required_beneficiaries'; + + -- Return early if empty array + IF jsonb_array_length(_required_beneficiaries) = 0 THEN + RETURN QUERY SELECT FALSE, ''::TEXT, NULL::INTEGER, NULL::INTEGER; + RETURN; + END IF; + + -- Validate each required beneficiary + FOR _required_ben IN SELECT * FROM jsonb_array_elements(_required_beneficiaries) + LOOP + _required_account := _required_ben->>'account'; + _required_weight := (_required_ben->>'weight')::INTEGER; + _found_weight := 0; + + -- Find matching beneficiary in post + FOR _post_ben IN SELECT * FROM jsonb_array_elements(_beneficiaries) + LOOP + IF _post_ben->>'account' = _required_account THEN + _found_weight := (_post_ben->>'weight')::INTEGER; + EXIT; + END IF; + END LOOP; + + -- Check if beneficiary is missing or insufficient + IF _found_weight = 0 THEN + _validation_failed := TRUE; + _error_parts := array_append(_error_parts, + format('%s (required: %s%%, provided: 0%%)', _required_account, (_required_weight::NUMERIC / 100)::TEXT)); + ELSIF _found_weight < _required_weight THEN + _validation_failed := TRUE; + _error_parts := array_append(_error_parts, + format('%s (required: %s%%, provided: %s%%)', _required_account, + (_required_weight::NUMERIC / 100)::TEXT, (_found_weight::NUMERIC / 100)::TEXT)); + END IF; + END LOOP; + + -- If validation failed, mute the post + IF _validation_failed THEN + -- Get existing muted reasons + IF _post_is_muted AND _post_muted_reasons IS NOT NULL THEN + _existing_muted_reasons := hivemind_app.decode_bitwise_mask(_post_muted_reasons); + END IF; + + -- Add MUTED_BENEFICIARY (5) if not already present + IF NOT (5 = ANY(_existing_muted_reasons)) THEN + _existing_muted_reasons := array_append(_existing_muted_reasons, 5); + END IF; + + _new_muted_reasons := hivemind_app.encode_bitwise_mask(_existing_muted_reasons); + + -- Update post + UPDATE hivemind_app.hive_posts + SET is_muted = TRUE, muted_reasons = _new_muted_reasons + WHERE id = _post_id; + + -- Return that we should notify with error message + RETURN QUERY SELECT TRUE, + 'Post does not meet required beneficiaries: ' || array_to_string(_error_parts, ', '), + _post_author_id, + _post_community_id; + ELSE + RETURN QUERY SELECT FALSE, ''::TEXT, NULL::INTEGER, NULL::INTEGER; + END IF; +END; +$$; diff --git a/hive/db/sql_scripts/postgrest/utilities/muted_reasons_operations.sql b/hive/db/sql_scripts/postgrest/utilities/muted_reasons_operations.sql index e7bd97c90..b9d23ad95 100644 --- a/hive/db/sql_scripts/postgrest/utilities/muted_reasons_operations.sql +++ b/hive/db/sql_scripts/postgrest/utilities/muted_reasons_operations.sql @@ -11,7 +11,8 @@ BEGIN 'MUTED_COMMUNITY_TYPE', 1, 'MUTED_PARENT', 2, 'MUTED_REPUTATION', 3, - 'MUTED_ROLE_COMMUNITY', 4 + 'MUTED_ROLE_COMMUNITY', 4, + 'MUTED_BENEFICIARY', 5 ); END; $BODY$ diff --git a/hive/indexer/blocks.py b/hive/indexer/blocks.py index a5cad1687..3a336f32b 100644 --- a/hive/indexer/blocks.py +++ b/hive/indexer/blocks.py @@ -379,7 +379,7 @@ class Blocks: if key not in ineffective_deleted_ops: Posts.delete_op(op, cls._head_block_date) elif op_type == OperationType.COMMENT_OPTION: - Posts.comment_options_op(op) + Posts.comment_options_op(op, cls._head_block_date) elif op_type == OperationType.VOTE: Votes.vote_op(op, cls._head_block_date) diff --git a/hive/indexer/community.py b/hive/indexer/community.py index e097fe4b9..db4c6948d 100644 --- a/hive/indexer/community.py +++ b/hive/indexer/community.py @@ -624,6 +624,35 @@ class CommunityOp: if 'cover_url' in settings: cover_url = settings['cover_url'] assert not cover_url or _valid_url_proto(cover_url) + if 'required_beneficiaries' in settings: + required_beneficiaries = settings['required_beneficiaries'] + assert isinstance(required_beneficiaries, list), 'required_beneficiaries must be a list' + assert len(required_beneficiaries) <= 8, 'too many required beneficiaries (max 8)' + + total_weight = 0 + seen_accounts = set() + + for beneficiary in required_beneficiaries: + assert isinstance(beneficiary, dict), 'each beneficiary must be a dict' + assert 'account' in beneficiary, 'beneficiary missing account field' + assert 'weight' in beneficiary, 'beneficiary missing weight field' + + account = beneficiary['account'] + weight = beneficiary['weight'] + + assert isinstance(account, str), 'beneficiary account must be string' + assert isinstance(weight, int), 'beneficiary weight must be integer' + assert len(account) >= 3 and len(account) <= 16, 'invalid account name length' + assert weight > 0 and weight <= 10000, 'weight must be between 1 and 10000 basis points' + assert account not in seen_accounts, f'duplicate beneficiary account: {account}' + + # Verify account exists (using existing Accounts system) + assert Accounts.exists(account), f'beneficiary account does not exist: {account}' + + seen_accounts.add(account) + total_weight += weight + + assert total_weight <= 10000, 'total required beneficiary weight exceeds 100% (10000 basis points)' if 'type_id' in props: community_type = read_key_integer(props, 'type_id') assert community_type in valid_types, 'invalid community type' diff --git a/hive/indexer/posts.py b/hive/indexer/posts.py index db598a81e..97ae78b31 100644 --- a/hive/indexer/posts.py +++ b/hive/indexer/posts.py @@ -130,11 +130,15 @@ class Posts(DbAdapterHolder): reasons.append("community type does not allow non members to post") if 2 in muted_reasons: reasons.append("parent post/comment is muted") + if 5 in muted_reasons: + reasons.append("post does not meet required beneficiary settings") if len(reasons) == 1: payload = f"Post is muted because {reasons[0]}" - else: + elif len(reasons) == 2: payload = f"Post is muted because {reasons[0]} and {reasons[1]}" + else: + payload = f"Post is muted because {', '.join(reasons[:-1])}, and {reasons[-1]}" Notify( block_num=op['block_num'], @@ -367,7 +371,7 @@ class Posts(DbAdapterHolder): DbAdapterHolder.common_block_processing_db().query(sql, child_id=child_id) @classmethod - def comment_options_op(cls, op): + def comment_options_op(cls, op, block_date): """Process comment_options_operation""" max_accepted_payout = ( legacy_amount(op['max_accepted_payout']) if 'max_accepted_payout' in op else '1000000.000 HBD' @@ -404,6 +408,29 @@ class Posts(DbAdapterHolder): beneficiaries=dumps(beneficiaries), ) + # Validate required beneficiaries for community posts + from hive.indexer.notify import Notify + sql = f"SELECT * FROM {SCHEMA_NAME}.validate_required_beneficiaries(:author, :permlink, :beneficiaries)" + result = DbAdapterHolder.common_block_processing_db().query_row( + sql, + author=op['author'], + permlink=op['permlink'], + beneficiaries=dumps(beneficiaries) + ) + + if result and result['should_mute']: + # Send error notification to author + Notify( + block_num=op['block_num'], + type_id='error', + dst_id=result['author_id'], + when=block_date, + post_id=None, # We don't have post_id from SQL function, but it's optional + payload=result['error_message'], + community_id=result['community_id'], + src_id=result['community_id'], + ) + @classmethod def delete(cls, op, block_date): """Marks a post record as being deleted.""" -- GitLab From f81f7fbb62dcee8c82fb1de7a767dfceb30c33be Mon Sep 17 00:00:00 2001 From: Howo Date: Sun, 21 Dec 2025 19:13:12 -0500 Subject: [PATCH 2/9] Added mocks to test feature --- .../mock_block_data_community.json | 245 ++++++++++++++++++ 1 file changed, 245 insertions(+) diff --git a/mock_data/block_data/community_op/mock_block_data_community.json b/mock_data/block_data/community_op/mock_block_data_community.json index 01697422a..14a5204a3 100644 --- a/mock_data/block_data/community_op/mock_block_data_community.json +++ b/mock_data/block_data/community_op/mock_block_data_community.json @@ -6527,5 +6527,250 @@ ] } ] + }, + "5000014": { + "transactions": [ + { + "ref_block_num": 100002, + "ref_block_prefix": 2, + "expiration": "2020-03-23T12:18:00", + "operations": [ + { + "type": "account_create_operation", + "value": { + "creator": "test-safari", + "new_account_name": "hive-187432", + "owner": { + "weight_threshold": 1, + "account_auths": [], + "key_auths": [ + [ + "STM8JH4fTJr73FQimysjmXCEh2UvRwZsG6ftjxsVTmYCeEehZgh25", + 1 + ] + ] + }, + "active": { + "weight_threshold": 1, + "account_auths": [], + "key_auths": [ + [ + "STM8JH4fTJr73FQimysjmXCEh2UvRwZsG6ftjxsVTmYCeEehZgh25", + 1 + ] + ] + }, + "posting": { + "weight_threshold": 1, + "account_auths": [], + "key_auths": [ + [ + "STM8JH4fTJr73FQimysjmXCEh2UvRwZsG6ftjxsVTmYCeEehZgh25", + 1 + ] + ] + }, + "memo_key": "STM8JH4fTJr73FQimysjmXCEh2UvRwZsG6ftjxsVTmYCeEehZgh25", + "json_metadata": "", + "extensions": [] + } + }, + { + "type": "account_create_operation", + "value": { + "creator": "test-safari", + "new_account_name": "benef-tester", + "owner": { + "weight_threshold": 1, + "account_auths": [], + "key_auths": [ + [ + "STM8JH4fTJr73FQimysjmXCEh2UvRwZsG6ftjxsVTmYCeEehZgh25", + 1 + ] + ] + }, + "active": { + "weight_threshold": 1, + "account_auths": [], + "key_auths": [ + [ + "STM8JH4fTJr73FQimysjmXCEh2UvRwZsG6ftjxsVTmYCeEehZgh25", + 1 + ] + ] + }, + "posting": { + "weight_threshold": 1, + "account_auths": [], + "key_auths": [ + [ + "STM8JH4fTJr73FQimysjmXCEh2UvRwZsG6ftjxsVTmYCeEehZgh25", + 1 + ] + ] + }, + "memo_key": "STM8JH4fTJr73FQimysjmXCEh2UvRwZsG6ftjxsVTmYCeEehZgh25", + "json_metadata": "", + "extensions": [] + } + }, + { + "type": "account_create_operation", + "value": { + "creator": "test-safari", + "new_account_name": "howo", + "owner": { + "weight_threshold": 1, + "account_auths": [], + "key_auths": [ + [ + "STM8JH4fTJr73FQimysjmXCEh2UvRwZsG6ftjxsVTmYCeEehZgh25", + 1 + ] + ] + }, + "active": { + "weight_threshold": 1, + "account_auths": [], + "key_auths": [ + [ + "STM8JH4fTJr73FQimysjmXCEh2UvRwZsG6ftjxsVTmYCeEehZgh25", + 1 + ] + ] + }, + "posting": { + "weight_threshold": 1, + "account_auths": [], + "key_auths": [ + [ + "STM8JH4fTJr73FQimysjmXCEh2UvRwZsG6ftjxsVTmYCeEehZgh25", + 1 + ] + ] + }, + "memo_key": "STM8JH4fTJr73FQimysjmXCEh2UvRwZsG6ftjxsVTmYCeEehZgh25", + "json_metadata": "", + "extensions": [] + } + }, + { + "type": "custom_json_operation", + "value": { + "required_auths": [], + "required_posting_auths": [ + "hive-187432" + ], + "id": "community", + "json": "[\"setRole\",{\"community\":\"hive-187432\",\"account\":\"test-safari\",\"role\":\"admin\"}]" + } + }, + { + "type": "custom_json_operation", + "value": { + "required_auths": [], + "required_posting_auths": [ + "test-safari" + ], + "id": "community", + "json": "[\"updateProps\",{\"community\":\"hive-187432\",\"props\":{\"title\":\"Beneficiary Test Community\",\"about\":\"Testing required beneficiaries\",\"is_nsfw\":false,\"description\":\"This community requires 5% beneficiary to howo\",\"flag_text\":\"\",\"settings\":{\"required_beneficiaries\":[{\"account\":\"howo\",\"weight\":500}]}}}]" + } + }, + { + "type": "comment_operation", + "value": { + "parent_author": "", + "parent_permlink": "hive-187432", + "author": "benef-tester", + "permlink": "post-with-beneficiary", + "title": "Post with correct beneficiary", + "body": "This post has the required 5% beneficiary to howo", + "json_metadata": "{}" + } + }, + { + "type": "comment_options_operation", + "value": { + "author": "benef-tester", + "permlink": "post-with-beneficiary", + "max_accepted_payout": { + "amount": "1000000000", + "nai": "@@000000013", + "precision": 3 + }, + "percent_hbd": 10000, + "allow_votes": true, + "allow_curation_rewards": true, + "extensions": [ + { + "type": "comment_payout_beneficiaries", + "value": { + "beneficiaries": [ + { + "account": "howo", + "weight": 500 + } + ] + } + } + ] + } + }, + { + "type": "comment_operation", + "value": { + "parent_author": "benef-tester", + "parent_permlink": "post-with-beneficiary", + "author": "test-safari", + "permlink": "comment-on-good-post", + "title": "", + "body": "This is a comment on a post with beneficiaries", + "json_metadata": "{}" + } + }, + { + "type": "comment_operation", + "value": { + "parent_author": "", + "parent_permlink": "hive-187432", + "author": "benef-tester", + "permlink": "post-without-beneficiary", + "title": "Post without beneficiary - should be muted", + "body": "This post does not have the required beneficiary and should be muted", + "json_metadata": "{}" + } + }, + { + "type": "comment_options_operation", + "value": { + "author": "benef-tester", + "permlink": "post-without-beneficiary", + "max_accepted_payout": { + "amount": "1000000000", + "nai": "@@000000013", + "precision": 3 + }, + "percent_hbd": 10000, + "allow_votes": true, + "allow_curation_rewards": true, + "extensions": [] + } + }, + { + "type": "comment_operation", + "value": { + "parent_author": "benef-tester", + "parent_permlink": "post-without-beneficiary", + "author": "test-safari", + "permlink": "comment-on-muted-post", + "title": "", + "body": "This is a comment on a muted post - comments should not be muted", + "json_metadata": "{}" + } + } + ] + } + ] } } -- GitLab From de82b2088280401aeda75b32132c6745fce8507e Mon Sep 17 00:00:00 2001 From: Howo Date: Tue, 23 Dec 2025 15:33:11 -0500 Subject: [PATCH 3/9] Add unit tests for required beneficiaries feature - Test get_community for hive-187432 with required_beneficiaries - Test get_discussion for post with correct beneficiary (not muted) - Test get_discussion for post without beneficiary (muted with reason 5) --- .../get_community/hive-187432.tavern.yaml | 28 +++++++++++++++++++ ...f-tester_post-with-beneficiary.tavern.yaml | 28 +++++++++++++++++++ ...ester_post-without-beneficiary.tavern.yaml | 28 +++++++++++++++++++ 3 files changed, 84 insertions(+) create mode 100644 tests/api_tests/hivemind/tavern/bridge_api_patterns/get_community/hive-187432.tavern.yaml create mode 100644 tests/api_tests/hivemind/tavern/bridge_api_patterns/get_discussion/benef-tester_post-with-beneficiary.tavern.yaml create mode 100644 tests/api_tests/hivemind/tavern/bridge_api_patterns/get_discussion/benef-tester_post-without-beneficiary.tavern.yaml diff --git a/tests/api_tests/hivemind/tavern/bridge_api_patterns/get_community/hive-187432.tavern.yaml b/tests/api_tests/hivemind/tavern/bridge_api_patterns/get_community/hive-187432.tavern.yaml new file mode 100644 index 000000000..c91dd5618 --- /dev/null +++ b/tests/api_tests/hivemind/tavern/bridge_api_patterns/get_community/hive-187432.tavern.yaml @@ -0,0 +1,28 @@ +--- + test_name: Hivemind + + marks: + - patterntest + + + includes: + - !include ../../common.yaml + + stages: + - name: test + request: + url: "{service.proto:s}://{service.server:s}:{service.port}/" + method: POST + headers: + content-type: application/json + json: + jsonrpc: "2.0" + id: 1 + method: "bridge.get_community" + params: {"name":"hive-187432"} + response: + status_code: 200 + verify_response_with: + function: validate_response:compare_response_with_pattern + extra_kwargs: + ignore_tags: "" diff --git a/tests/api_tests/hivemind/tavern/bridge_api_patterns/get_discussion/benef-tester_post-with-beneficiary.tavern.yaml b/tests/api_tests/hivemind/tavern/bridge_api_patterns/get_discussion/benef-tester_post-with-beneficiary.tavern.yaml new file mode 100644 index 000000000..5e29400b7 --- /dev/null +++ b/tests/api_tests/hivemind/tavern/bridge_api_patterns/get_discussion/benef-tester_post-with-beneficiary.tavern.yaml @@ -0,0 +1,28 @@ +--- + test_name: Hivemind + + marks: + - patterntest + + + includes: + - !include ../../common.yaml + + stages: + - name: test + request: + url: "{service.proto:s}://{service.server:s}:{service.port}/" + method: POST + headers: + content-type: application/json + json: + jsonrpc: "2.0" + id: 1 + method: "bridge.get_discussion" + params: {"author":"benef-tester", "permlink":"post-with-beneficiary"} + response: + status_code: 200 + verify_response_with: + function: validate_response:compare_response_with_pattern + extra_kwargs: + ignore_tags: "" diff --git a/tests/api_tests/hivemind/tavern/bridge_api_patterns/get_discussion/benef-tester_post-without-beneficiary.tavern.yaml b/tests/api_tests/hivemind/tavern/bridge_api_patterns/get_discussion/benef-tester_post-without-beneficiary.tavern.yaml new file mode 100644 index 000000000..beed4232d --- /dev/null +++ b/tests/api_tests/hivemind/tavern/bridge_api_patterns/get_discussion/benef-tester_post-without-beneficiary.tavern.yaml @@ -0,0 +1,28 @@ +--- + test_name: Hivemind + + marks: + - patterntest + + + includes: + - !include ../../common.yaml + + stages: + - name: test + request: + url: "{service.proto:s}://{service.server:s}:{service.port}/" + method: POST + headers: + content-type: application/json + json: + jsonrpc: "2.0" + id: 1 + method: "bridge.get_discussion" + params: {"author":"benef-tester", "permlink":"post-without-beneficiary"} + response: + status_code: 200 + verify_response_with: + function: validate_response:compare_response_with_pattern + extra_kwargs: + ignore_tags: "" -- GitLab From 5498737c710790a45c902416e1a53939f6c816a0 Mon Sep 17 00:00:00 2001 From: Howo Date: Fri, 26 Dec 2025 14:39:08 -0500 Subject: [PATCH 4/9] Trigger pipeline -- GitLab From 71d77bfc0ed15d39827adef218f350697bc7aaef Mon Sep 17 00:00:00 2001 From: Howo Date: Sun, 28 Dec 2025 19:14:52 -0500 Subject: [PATCH 5/9] Fixed duplicate account in tests --- .../community_op/mock_block_data_community.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/mock_data/block_data/community_op/mock_block_data_community.json b/mock_data/block_data/community_op/mock_block_data_community.json index 14a5204a3..4353362a6 100644 --- a/mock_data/block_data/community_op/mock_block_data_community.json +++ b/mock_data/block_data/community_op/mock_block_data_community.json @@ -6619,7 +6619,7 @@ "type": "account_create_operation", "value": { "creator": "test-safari", - "new_account_name": "howo", + "new_account_name": "acidyo", "owner": { "weight_threshold": 1, "account_auths": [], @@ -6674,7 +6674,7 @@ "test-safari" ], "id": "community", - "json": "[\"updateProps\",{\"community\":\"hive-187432\",\"props\":{\"title\":\"Beneficiary Test Community\",\"about\":\"Testing required beneficiaries\",\"is_nsfw\":false,\"description\":\"This community requires 5% beneficiary to howo\",\"flag_text\":\"\",\"settings\":{\"required_beneficiaries\":[{\"account\":\"howo\",\"weight\":500}]}}}]" + "json": "[\"updateProps\",{\"community\":\"hive-187432\",\"props\":{\"title\":\"Beneficiary Test Community\",\"about\":\"Testing required beneficiaries\",\"is_nsfw\":false,\"description\":\"This community requires 5% beneficiary to howo\",\"flag_text\":\"\",\"settings\":{\"required_beneficiaries\":[{\"account\":\"acidyo\",\"weight\":500}]}}}]" } }, { @@ -6685,7 +6685,7 @@ "author": "benef-tester", "permlink": "post-with-beneficiary", "title": "Post with correct beneficiary", - "body": "This post has the required 5% beneficiary to howo", + "body": "This post has the required 5% beneficiary to acidyo", "json_metadata": "{}" } }, @@ -6708,7 +6708,7 @@ "value": { "beneficiaries": [ { - "account": "howo", + "account": "acidyo", "weight": 500 } ] -- GitLab From f46d1a5ff2759e1951fab65172572187e849f0d9 Mon Sep 17 00:00:00 2001 From: Howo Date: Mon, 29 Dec 2025 14:59:37 -0500 Subject: [PATCH 6/9] fixed already existing account again --- .../community_op/mock_block_data_community.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/mock_data/block_data/community_op/mock_block_data_community.json b/mock_data/block_data/community_op/mock_block_data_community.json index 4353362a6..748d0faab 100644 --- a/mock_data/block_data/community_op/mock_block_data_community.json +++ b/mock_data/block_data/community_op/mock_block_data_community.json @@ -6619,7 +6619,7 @@ "type": "account_create_operation", "value": { "creator": "test-safari", - "new_account_name": "acidyo", + "new_account_name": "acidyobene", "owner": { "weight_threshold": 1, "account_auths": [], @@ -6674,7 +6674,7 @@ "test-safari" ], "id": "community", - "json": "[\"updateProps\",{\"community\":\"hive-187432\",\"props\":{\"title\":\"Beneficiary Test Community\",\"about\":\"Testing required beneficiaries\",\"is_nsfw\":false,\"description\":\"This community requires 5% beneficiary to howo\",\"flag_text\":\"\",\"settings\":{\"required_beneficiaries\":[{\"account\":\"acidyo\",\"weight\":500}]}}}]" + "json": "[\"updateProps\",{\"community\":\"hive-187432\",\"props\":{\"title\":\"Beneficiary Test Community\",\"about\":\"Testing required beneficiaries\",\"is_nsfw\":false,\"description\":\"This community requires 5% beneficiary to howo\",\"flag_text\":\"\",\"settings\":{\"required_beneficiaries\":[{\"account\":\"acidyobene\",\"weight\":500}]}}}]" } }, { @@ -6685,7 +6685,7 @@ "author": "benef-tester", "permlink": "post-with-beneficiary", "title": "Post with correct beneficiary", - "body": "This post has the required 5% beneficiary to acidyo", + "body": "This post has the required 5% beneficiary to acidyobene", "json_metadata": "{}" } }, @@ -6708,7 +6708,7 @@ "value": { "beneficiaries": [ { - "account": "acidyo", + "account": "acidyobene", "weight": 500 } ] -- GitLab From 1b0b22ee519fda64274a62e461f727a902424efc Mon Sep 17 00:00:00 2001 From: Howo Date: Mon, 29 Dec 2025 15:38:42 -0500 Subject: [PATCH 7/9] fix query column naming --- hive/indexer/posts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hive/indexer/posts.py b/hive/indexer/posts.py index 97ae78b31..ff1ee0c7f 100644 --- a/hive/indexer/posts.py +++ b/hive/indexer/posts.py @@ -410,7 +410,7 @@ class Posts(DbAdapterHolder): # Validate required beneficiaries for community posts from hive.indexer.notify import Notify - sql = f"SELECT * FROM {SCHEMA_NAME}.validate_required_beneficiaries(:author, :permlink, :beneficiaries)" + sql = f"SELECT should_mute, error_message, author_id, community_id FROM {SCHEMA_NAME}.validate_required_beneficiaries(:author, :permlink, :beneficiaries)" result = DbAdapterHolder.common_block_processing_db().query_row( sql, author=op['author'], -- GitLab From 5d3a267c9cefd3ea2a76c06fd5decf19ffbdc4c3 Mon Sep 17 00:00:00 2001 From: Howo Date: Mon, 29 Dec 2025 16:01:38 -0500 Subject: [PATCH 8/9] Fix SQLAlchemy Row access in comment_options_op --- hive/indexer/posts.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/hive/indexer/posts.py b/hive/indexer/posts.py index ff1ee0c7f..2daf21274 100644 --- a/hive/indexer/posts.py +++ b/hive/indexer/posts.py @@ -418,6 +418,9 @@ class Posts(DbAdapterHolder): beneficiaries=dumps(beneficiaries) ) + if result: + result = result._mapping + if result and result['should_mute']: # Send error notification to author Notify( -- GitLab From ddb30066fa59e64b78a490baf58d0a114d2b3b24 Mon Sep 17 00:00:00 2001 From: Howo Date: Tue, 30 Dec 2025 17:25:45 -0500 Subject: [PATCH 9/9] Updated processing to cache posts to be faster --- hive/indexer/blocks.py | 4 + hive/indexer/posts.py | 196 ++++++++++++++++++++++++++++++++++++----- 2 files changed, 177 insertions(+), 23 deletions(-) diff --git a/hive/indexer/blocks.py b/hive/indexer/blocks.py index 3a336f32b..59078c911 100644 --- a/hive/indexer/blocks.py +++ b/hive/indexer/blocks.py @@ -390,6 +390,10 @@ class Blocks: OPSM.op_stats(str(op_type), OPSM.stop(start)) cls._head_block_date = cls._current_block_date + + # Batch process required beneficiaries validation for community posts + Posts.process_required_beneficiaries_batch(cls._current_block_date, num) + return num @staticmethod diff --git a/hive/indexer/posts.py b/hive/indexer/posts.py index 2daf21274..47369387b 100644 --- a/hive/indexer/posts.py +++ b/hive/indexer/posts.py @@ -29,6 +29,7 @@ class Posts(DbAdapterHolder): comment_payout_ops = {} _comment_payout_ops = [] _counter = UniqueCounter() + _community_posts_tracker = {} # Tracks posts in communities for batch beneficiary validation @classmethod def delete_op(cls, op, block_date): @@ -104,6 +105,17 @@ class Posts(DbAdapterHolder): # log.info("Adding author: {} permlink: {}".format(op['author'], op['permlink'])) PostDataCache.add_data(result['id'], post_data, is_new_post) + + # Track new community posts for batch beneficiary validation + if is_new_post and row['depth'] == 0 and row['community_id'] is not None: + cls._community_posts_tracker[(op['author'], op['permlink'])] = { + 'post_id': result['id'], + 'community_id': row['community_id'], + 'author_id': row['author_id'], + 'has_comment_options': False, + 'beneficiaries': None, + } + if row['depth'] > 0: type_id = 12 if row['depth'] == 1 else 13 key = f"{row['author_id']}/{row['parent_author_id']}/{type_id}/{row['id']}" @@ -408,31 +420,169 @@ class Posts(DbAdapterHolder): beneficiaries=dumps(beneficiaries), ) - # Validate required beneficiaries for community posts - from hive.indexer.notify import Notify - sql = f"SELECT should_mute, error_message, author_id, community_id FROM {SCHEMA_NAME}.validate_required_beneficiaries(:author, :permlink, :beneficiaries)" - result = DbAdapterHolder.common_block_processing_db().query_row( - sql, - author=op['author'], - permlink=op['permlink'], - beneficiaries=dumps(beneficiaries) - ) + # Update tracker if this post is being tracked for beneficiary validation + key = (op['author'], op['permlink']) + if key in cls._community_posts_tracker: + cls._community_posts_tracker[key]['has_comment_options'] = True + cls._community_posts_tracker[key]['beneficiaries'] = beneficiaries - if result: - result = result._mapping + @classmethod + def _validate_beneficiaries(cls, post_beneficiaries, required_beneficiaries): + """Validate post beneficiaries against community required beneficiaries. - if result and result['should_mute']: - # Send error notification to author - Notify( - block_num=op['block_num'], - type_id='error', - dst_id=result['author_id'], - when=block_date, - post_id=None, # We don't have post_id from SQL function, but it's optional - payload=result['error_message'], - community_id=result['community_id'], - src_id=result['community_id'], - ) + Args: + post_beneficiaries: List of beneficiary dicts with 'account' and 'weight' keys + required_beneficiaries: JSONB array from community settings + + Returns: + Error message string if validation fails, None if valid + """ + if not required_beneficiaries: + return None + + # Parse required_beneficiaries if it's a JSON string + if isinstance(required_beneficiaries, str): + required_beneficiaries = loads(required_beneficiaries) + + error_parts = [] + + for required_ben in required_beneficiaries: + required_account = required_ben['account'] + required_weight = int(required_ben['weight']) + found_weight = 0 + + # Find matching beneficiary in post + if post_beneficiaries: + for post_ben in post_beneficiaries: + if post_ben['account'] == required_account: + found_weight = int(post_ben['weight']) + break + + # Check if beneficiary is missing or insufficient + if found_weight == 0: + error_parts.append( + f"{required_account} (required: {required_weight / 100}%, provided: 0%)" + ) + elif found_weight < required_weight: + error_parts.append( + f"{required_account} (required: {required_weight / 100}%, provided: {found_weight / 100}%)" + ) + + if error_parts: + return 'Post does not meet required beneficiaries: ' + ', '.join(error_parts) + return None + + @classmethod + def _batch_mute_posts(cls, posts_to_mute): + """Batch update posts to add MUTED_BENEFICIARY (5) reason. + + Args: + posts_to_mute: List of dicts with 'post_id' keys + """ + if not posts_to_mute: + return + + # Extract just the post IDs + post_ids = [p['post_id'] for p in posts_to_mute] + + # Use SQL to batch update all posts, adding MUTED_BENEFICIARY (5) to muted_reasons + sql = f""" + UPDATE {SCHEMA_NAME}.hive_posts + SET + is_muted = TRUE, + muted_reasons = CASE + WHEN muted_reasons IS NULL THEN {SCHEMA_NAME}.encode_bitwise_mask(ARRAY[5]) + WHEN 5 = ANY({SCHEMA_NAME}.decode_bitwise_mask(muted_reasons)) THEN muted_reasons + ELSE {SCHEMA_NAME}.encode_bitwise_mask( + array_append({SCHEMA_NAME}.decode_bitwise_mask(muted_reasons), 5) + ) + END + WHERE id = ANY(:post_ids) + """ + + DbAdapterHolder.common_block_processing_db().query_no_return(sql, post_ids=post_ids) + + @classmethod + def process_required_beneficiaries_batch(cls, block_date, block_num): + """Batch process required beneficiaries validation for all tracked community posts. + + This is called at the end of block processing to validate all posts in communities + that have required_beneficiaries settings. + + Args: + block_date: Timestamp of the current block + block_num: Block number being processed + """ + if not cls._community_posts_tracker: + return + + # Get unique community IDs from tracked posts + community_ids = list({p['community_id'] for p in cls._community_posts_tracker.values()}) + + # Fetch community settings ONCE for all communities that have required_beneficiaries + sql = f""" + SELECT id, settings->'required_beneficiaries' as required_beneficiaries + FROM {SCHEMA_NAME}.hive_communities + WHERE id = ANY(:community_ids) + AND settings ? 'required_beneficiaries' + AND jsonb_array_length(settings->'required_beneficiaries') > 0 + """ + + rows = DbAdapterHolder.common_block_processing_db().query_all(sql, community_ids=community_ids) + + # Build a map of community_id -> required_beneficiaries + settings_map = {} + for row in rows: + row = row._mapping + settings_map[row['id']] = row['required_beneficiaries'] + + posts_to_mute = [] + + # Process each tracked post + for (author, permlink), post_data in cls._community_posts_tracker.items(): + community_id = post_data['community_id'] + + # Skip if community doesn't have required_beneficiaries + if community_id not in settings_map: + continue + + error_message = None + + # CHEAP CHECK: No comment_options_op received -> auto-mute + if not post_data['has_comment_options']: + error_message = 'Post does not meet required beneficiaries: missing comment_options operation' + else: + # Validate beneficiaries against requirements + error_message = cls._validate_beneficiaries( + post_data['beneficiaries'], + settings_map[community_id] + ) + + # If validation failed, add to mute list and send notification + if error_message: + posts_to_mute.append({ + 'post_id': post_data['post_id'], + 'error': error_message + }) + + # Send error notification to author + Notify( + block_num=block_num, + type_id='error', + dst_id=post_data['author_id'], + when=block_date, + post_id=post_data['post_id'], + payload=error_message, + community_id=community_id, + src_id=community_id, + ) + + # Batch update all posts that need muting + if posts_to_mute: + cls._batch_mute_posts(posts_to_mute) + + # Clear tracker for next block + cls._community_posts_tracker = {} @classmethod def delete(cls, op, block_date): -- GitLab