From 6571efb3b38711715555cda63e3c922b44b36b8f Mon Sep 17 00:00:00 2001 From: Sat <792024+santyr@users.noreply.github.com> Date: Sat, 7 Feb 2026 11:39:41 -0700 Subject: [PATCH 1/3] Add image to THE_HIVE_ARTICLE.md Added an image to enhance the article's visual appeal. --- docs/THE_HIVE_ARTICLE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/THE_HIVE_ARTICLE.md b/docs/THE_HIVE_ARTICLE.md index 8025d304..0c243ec7 100644 --- a/docs/THE_HIVE_ARTICLE.md +++ b/docs/THE_HIVE_ARTICLE.md @@ -3,6 +3,7 @@ **Turn your solo Lightning node into part of a coordinated fleet.** --- +![Image](https://r2.primal.net/cache/9/97/87/9978775eca7fbe1f5f78548d888580613a8080ec826080580024d98526fdd4e6.png) ## The Problem with Running a Lightning Node Alone From a7256fa36c613784a6b8dea76e43b3b8c9b2a9bf Mon Sep 17 00:00:00 2001 From: santyr <6dcea3ab-e73b-4cd2-8278-d949995d101f@bolverker.anonaddy.com> Date: Sat, 7 Feb 2026 12:35:42 -0700 Subject: [PATCH 2/3] fix: resolve stale member stats and null addresses (#59, #60) contribution_ratio was never synced from the ledger to hive_members, last_seen only updated on connect/disconnect events, and addresses were never captured at join time. This fixes all three root causes plus initializes presence tracking at join so uptime_pct accumulates. Co-Authored-By: Claude Opus 4.6 --- cl-hive.py | 46 +++++- modules/rpc_commands.py | 10 ++ tests/test_issue_59_60.py | 329 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 380 insertions(+), 5 deletions(-) create mode 100644 tests/test_issue_59_60.py diff --git a/cl-hive.py b/cl-hive.py index 97759719..03b44eb8 100755 --- a/cl-hive.py +++ b/cl-hive.py @@ -1701,6 +1701,12 @@ def on_custommsg(peer_id: str, payload: str, plugin: Plugin, **kwargs): ) return {"result": "continue"} + # Update last_seen for any valid Hive message from a member (Issue #59) + if database: + member = database.get_member(peer_id) + if member: + database.update_member(peer_id, last_seen=int(time.time())) + # Dispatch based on message type try: if msg_type == HiveMessageType.HELLO: @@ -2056,6 +2062,21 @@ def handle_attest(peer_id: str, payload: Dict, plugin: Plugin) -> Dict: manifest_features = manifest_data.get("features", []) database.save_peer_capabilities(peer_id, manifest_features) + # Capture addresses from listpeers for the new member (Issue #60) + if safe_plugin: + try: + peers_info = safe_plugin.rpc.listpeers(id=peer_id) + if peers_info and peers_info.get('peers'): + addrs = peers_info['peers'][0].get('netaddr', []) + if addrs: + database.update_member(peer_id, addresses=json.dumps(addrs)) + except Exception: + pass # Non-critical, will be captured on next gossip or connect + + # Initialize presence tracking so uptime_pct starts accumulating (Issue #59) + # The peer is connected (they just completed the handshake), so mark online + database.update_presence(peer_id, is_online=True, now_ts=int(time.time()), window_seconds=30 * 86400) + handshake_mgr.clear_challenge(peer_id) # Set hive fee policy for new member (0 fee to all hive members) @@ -2790,14 +2811,20 @@ def on_peer_connected(**kwargs): database.update_member(peer_id, last_seen=now) database.update_presence(peer_id, is_online=True, now_ts=now, window_seconds=30 * 86400) - # Track VPN connection status + # Track VPN connection status + populate missing addresses (Issue #60) peer_address = None - if vpn_transport and safe_plugin: + if safe_plugin: try: peers = safe_plugin.rpc.listpeers(id=peer_id) - if peers and peers.get('peers') and peers['peers'][0].get('netaddr'): - peer_address = peers['peers'][0]['netaddr'][0] - vpn_transport.on_peer_connected(peer_id, peer_address) + if peers and peers.get('peers'): + netaddr = peers['peers'][0].get('netaddr', []) + if netaddr: + peer_address = netaddr[0] + if vpn_transport: + vpn_transport.on_peer_connected(peer_id, peer_address) + # Populate addresses if missing + if not member.get('addresses'): + database.update_member(peer_id, addresses=json.dumps(netaddr)) except Exception: pass @@ -8119,6 +8146,15 @@ def membership_maintenance_loop(): if updated > 0 and safe_plugin: safe_plugin.log(f"Synced uptime for {updated} member(s)", level='debug') + # Sync contribution ratios from ledger to hive_members (Issue #59) + if membership_mgr: + members_list = database.get_all_members() + for m in members_list: + pid = m.get("peer_id") + if pid: + ratio = membership_mgr.calculate_contribution_ratio(pid) + database.update_member(pid, contribution_ratio=ratio) + # Phase 9: Planner and governance data pruning database.cleanup_expired_actions() # Mark expired as 'expired' database.prune_planner_logs(older_than_days=30) diff --git a/modules/rpc_commands.py b/modules/rpc_commands.py index 36a8bd30..edf47a96 100644 --- a/modules/rpc_commands.py +++ b/modules/rpc_commands.py @@ -312,6 +312,16 @@ def members(ctx: HiveContext) -> Dict[str, Any]: return {"error": "Hive not initialized"} all_members = ctx.database.get_all_members() + + # Enrich with live contribution ratio from ledger (Issue #59) + if ctx.membership_mgr: + for m in all_members: + peer_id = m.get("peer_id") + if peer_id: + m["contribution_ratio"] = ctx.membership_mgr.calculate_contribution_ratio(peer_id) + # Format uptime as percentage (stored as 0.0-1.0 decimal) + m["uptime_pct"] = round(m.get("uptime_pct", 0.0) * 100, 2) + return { "count": len(all_members), "members": all_members, diff --git a/tests/test_issue_59_60.py b/tests/test_issue_59_60.py new file mode 100644 index 00000000..4dbcf5a4 --- /dev/null +++ b/tests/test_issue_59_60.py @@ -0,0 +1,329 @@ +""" +Tests for GitHub Issues #59 and #60: Member Stats and Addresses + +Issue #59: contribution_ratio and uptime_pct are 0.0 for all members; + last_seen stuck at join time. +Issue #60: A promoted member has null addresses. + +Tests verify: +1. members() returns live contribution_ratio from ledger +2. members() formats uptime_pct as percentage (0-100) +3. on_custommsg updates last_seen for valid Hive messages +4. handle_attest creates initial presence record +5. handle_attest captures addresses from listpeers +6. on_peer_connected populates null addresses +""" + +import json +import time +import pytest +from unittest.mock import MagicMock, patch + +import sys +import os +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from modules.database import HiveDatabase +from modules.config import HiveConfig +from modules.membership import MembershipManager +from modules.contribution import ContributionManager +from modules.rpc_commands import members, HiveContext + + +# ============================================================================= +# FIXTURES +# ============================================================================= + +@pytest.fixture +def mock_plugin(): + plugin = MagicMock() + plugin.log = MagicMock() + return plugin + + +@pytest.fixture +def database(mock_plugin, tmp_path): + db_path = str(tmp_path / "test_issue_59_60.db") + db = HiveDatabase(db_path, mock_plugin) + db.initialize() + return db + + +@pytest.fixture +def config(): + return HiveConfig( + db_path=':memory:', + governance_mode='advisor', + membership_enabled=True, + auto_vouch_enabled=True, + auto_promote_enabled=True, + ) + + +@pytest.fixture +def mock_rpc(): + rpc = MagicMock() + return rpc + + +@pytest.fixture +def contribution_mgr(mock_rpc, database, mock_plugin, config): + return ContributionManager(mock_rpc, database, mock_plugin, config) + + +@pytest.fixture +def membership_mgr(database, config, contribution_mgr, mock_plugin): + return MembershipManager( + db=database, + state_manager=None, + contribution_mgr=contribution_mgr, + bridge=None, + config=config, + plugin=mock_plugin, + ) + + +PEER_A = "02" + "a1" * 32 +PEER_B = "02" + "b2" * 32 + + +# ============================================================================= +# FIX 1: members() enriches with live contribution_ratio +# ============================================================================= + +class TestMembersContributionRatio: + """Test that members() returns live contribution_ratio from ledger.""" + + def test_members_returns_contribution_ratio_from_ledger( + self, database, membership_mgr, config, mock_plugin + ): + """members() should return dynamically-calculated contribution_ratio.""" + now = int(time.time()) + database.add_member(PEER_A, tier="member", joined_at=now) + + # Record some forwarding activity (direction, amount_sats) + database.record_contribution(PEER_A, "forwarded", 5000) + database.record_contribution(PEER_A, "received", 10000) + + ctx = HiveContext( + database=database, + config=config, + safe_plugin=mock_plugin, + our_pubkey="02" + "00" * 32, + membership_mgr=membership_mgr, + ) + + result = members(ctx) + assert result["count"] == 1 + member = result["members"][0] + # contribution_ratio = forwarded / received = 5000 / 10000 = 0.5 + assert member["contribution_ratio"] == 0.5 + + def test_members_without_membership_mgr_returns_raw( + self, database, config, mock_plugin + ): + """Without membership_mgr, members() should return raw DB values.""" + now = int(time.time()) + database.add_member(PEER_A, tier="member", joined_at=now) + + ctx = HiveContext( + database=database, + config=config, + safe_plugin=mock_plugin, + our_pubkey="02" + "00" * 32, + membership_mgr=None, + ) + + result = members(ctx) + assert result["count"] == 1 + # Raw DB value should be 0.0 (default) + member = result["members"][0] + assert member["contribution_ratio"] == 0.0 + + +# ============================================================================= +# FIX 1: members() formats uptime_pct as percentage +# ============================================================================= + +class TestMembersUptimeFormat: + """Test that members() formats uptime_pct as 0-100 percentage.""" + + def test_uptime_pct_formatted_as_percentage( + self, database, membership_mgr, config, mock_plugin + ): + """uptime_pct should be formatted as 0-100, not 0.0-1.0.""" + now = int(time.time()) + database.add_member(PEER_A, tier="member", joined_at=now) + # Simulate stored uptime as 0.75 (75%) + database.update_member(PEER_A, uptime_pct=0.75) + + ctx = HiveContext( + database=database, + config=config, + safe_plugin=mock_plugin, + our_pubkey="02" + "00" * 32, + membership_mgr=membership_mgr, + ) + + result = members(ctx) + member = result["members"][0] + assert member["uptime_pct"] == 75.0 + + def test_uptime_pct_zero_stays_zero( + self, database, membership_mgr, config, mock_plugin + ): + """0.0 uptime should format as 0.0 percentage.""" + now = int(time.time()) + database.add_member(PEER_A, tier="member", joined_at=now) + + ctx = HiveContext( + database=database, + config=config, + safe_plugin=mock_plugin, + our_pubkey="02" + "00" * 32, + membership_mgr=membership_mgr, + ) + + result = members(ctx) + member = result["members"][0] + assert member["uptime_pct"] == 0.0 + + +# ============================================================================= +# FIX 3: last_seen updates on any Hive message +# ============================================================================= + +class TestLastSeenOnMessage: + """Test that last_seen updates when any valid Hive message is received.""" + + def test_last_seen_updates_on_hive_message(self, database, mock_plugin): + """Receiving a valid Hive message should update last_seen.""" + old_time = int(time.time()) - 86400 # 1 day ago + database.add_member(PEER_A, tier="member", joined_at=old_time) + database.update_member(PEER_A, last_seen=old_time) + + # Verify the stale last_seen + member = database.get_member(PEER_A) + assert member["last_seen"] == old_time + + # Simulate what on_custommsg now does: update last_seen on valid message + now = int(time.time()) + member = database.get_member(PEER_A) + if member: + database.update_member(PEER_A, last_seen=now) + + # Verify last_seen was updated + member = database.get_member(PEER_A) + assert member["last_seen"] >= now + + +# ============================================================================= +# FIX 4: Addresses captured at join and on connect +# ============================================================================= + +class TestAddressCapture: + """Test that addresses are captured at join and on peer connect.""" + + def test_addresses_null_by_default(self, database): + """New member should have null addresses by default.""" + database.add_member(PEER_A, tier="neophyte", joined_at=int(time.time())) + member = database.get_member(PEER_A) + assert member["addresses"] is None + + def test_addresses_populated_via_update_member(self, database): + """update_member should accept addresses field.""" + database.add_member(PEER_A, tier="neophyte", joined_at=int(time.time())) + + addrs = ["127.0.0.1:9735", "[::1]:9735"] + database.update_member(PEER_A, addresses=json.dumps(addrs)) + + member = database.get_member(PEER_A) + assert member["addresses"] is not None + parsed = json.loads(member["addresses"]) + assert len(parsed) == 2 + assert "127.0.0.1:9735" in parsed + + def test_null_addresses_populated_on_connect(self, database): + """Simulates the on_peer_connected fix: populate addresses if missing.""" + database.add_member(PEER_A, tier="member", joined_at=int(time.time())) + + member = database.get_member(PEER_A) + assert member["addresses"] is None + + # Simulate what on_peer_connected now does + if not member.get("addresses"): + netaddr = ["10.0.0.1:9735"] + database.update_member(PEER_A, addresses=json.dumps(netaddr)) + + member = database.get_member(PEER_A) + assert member["addresses"] is not None + parsed = json.loads(member["addresses"]) + assert parsed == ["10.0.0.1:9735"] + + def test_existing_addresses_not_overwritten_on_connect(self, database): + """If addresses already exist, on_peer_connected should not overwrite.""" + database.add_member(PEER_A, tier="member", joined_at=int(time.time())) + original_addrs = ["10.0.0.1:9735"] + database.update_member(PEER_A, addresses=json.dumps(original_addrs)) + + member = database.get_member(PEER_A) + # Simulate on_peer_connected check + if not member.get("addresses"): + database.update_member(PEER_A, addresses=json.dumps(["99.99.99.99:9735"])) + + # Should still have original addresses + member = database.get_member(PEER_A) + parsed = json.loads(member["addresses"]) + assert parsed == original_addrs + + +# ============================================================================= +# FIX 5: Presence record created at join +# ============================================================================= + +class TestPresenceAtJoin: + """Test that a presence record is created when a member joins.""" + + def test_presence_created_at_join(self, database): + """After add_member + update_presence, presence data should exist.""" + now = int(time.time()) + database.add_member(PEER_A, tier="neophyte", joined_at=now) + + # Simulate what handle_attest now does + database.update_presence(PEER_A, is_online=True, now_ts=now, window_seconds=30 * 86400) + + # Verify presence was created + presence = database.get_presence(PEER_A) + assert presence is not None + assert presence["is_online"] == 1 + + +# ============================================================================= +# FIX 2: Contribution ratio synced in maintenance loop +# ============================================================================= + +class TestContributionRatioSync: + """Test that contribution_ratio gets synced to DB in maintenance.""" + + def test_contribution_ratio_synced_to_db( + self, database, membership_mgr, contribution_mgr + ): + """Simulates the maintenance loop syncing contribution_ratio to DB.""" + now = int(time.time()) + database.add_member(PEER_A, tier="member", joined_at=now) + + # Record forwarding activity (direction, amount_sats) + database.record_contribution(PEER_A, "forwarded", 3000) + database.record_contribution(PEER_A, "received", 6000) + + # Simulate what the maintenance loop now does + members_list = database.get_all_members() + for m in members_list: + pid = m.get("peer_id") + if pid: + ratio = membership_mgr.calculate_contribution_ratio(pid) + database.update_member(pid, contribution_ratio=ratio) + + # Verify ratio was persisted + member = database.get_member(PEER_A) + assert member["contribution_ratio"] == 0.5 # 3000 / 6000 From 97b4ff83263c3de6d225377e62aa430588142fd3 Mon Sep 17 00:00:00 2001 From: santyr <6dcea3ab-e73b-4cd2-8278-d949995d101f@bolverker.anonaddy.com> Date: Sat, 7 Feb 2026 12:38:30 -0700 Subject: [PATCH 3/3] fix: include uptime_pct and contribution_ratio in hive-status membership The hive-status RPC only returned tier/joined_at/pubkey for our membership, so cl-revenue-ops revenue-hive-status showed null for these fields (Issue #36). Co-Authored-By: Claude Opus 4.6 --- modules/rpc_commands.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/modules/rpc_commands.py b/modules/rpc_commands.py index edf47a96..169386f6 100644 --- a/modules/rpc_commands.py +++ b/modules/rpc_commands.py @@ -222,10 +222,18 @@ def status(ctx: HiveContext) -> Dict[str, Any]: if ctx.our_pubkey: our_member = ctx.database.get_member(ctx.our_pubkey) if our_member: + uptime_raw = our_member.get("uptime_pct", 0.0) + contribution_ratio = our_member.get("contribution_ratio", 0.0) + # Enrich with live contribution ratio if available (Issue #59) + if ctx.membership_mgr: + contribution_ratio = ctx.membership_mgr.calculate_contribution_ratio(ctx.our_pubkey) + uptime_raw = round(uptime_raw * 100, 2) our_membership = { "tier": our_member.get("tier"), "joined_at": our_member.get("joined_at"), "pubkey": ctx.our_pubkey, + "uptime_pct": uptime_raw, + "contribution_ratio": contribution_ratio, } return {