Skip to content

Commit bd59394

Browse files
rustyrussellcdecker
authored andcommitted
pyln.client: new functionality to access Gossmap.
It doesn't do much work, but it does parse the gossmap file and extract nodes and channels. Signed-off-by: Rusty Russell <[email protected]>
1 parent 262f90d commit bd59394

File tree

5 files changed

+230
-2
lines changed

5 files changed

+230
-2
lines changed

contrib/pyln-client/pyln/client/__init__.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from .lightning import LightningRpc, RpcError, Millisatoshi
22
from .plugin import Plugin, monkey_patch, RpcException
3-
3+
from .gossmap import Gossmap
44

55
__version__ = "0.9.3"
66

@@ -12,5 +12,6 @@
1212
"RpcException",
1313
"Millisatoshi",
1414
"__version__",
15-
"monkey_patch"
15+
"monkey_patch",
16+
"Gossmap",
1617
]
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
#! /usr/bin/python3
2+
3+
from pyln.spec.bolt7 import (channel_announcement, channel_update,
4+
node_announcement)
5+
from typing import Dict, List, Optional
6+
7+
import io
8+
import struct
9+
10+
# These duplicate constants in lightning/common/gossip_store.h
11+
GOSSIP_STORE_VERSION = 9
12+
GOSSIP_STORE_LEN_DELETED_BIT = 0x80000000
13+
GOSSIP_STORE_LEN_PUSH_BIT = 0x40000000
14+
GOSSIP_STORE_LEN_MASK = (~(GOSSIP_STORE_LEN_PUSH_BIT
15+
| GOSSIP_STORE_LEN_DELETED_BIT))
16+
17+
# These duplicate constants in lightning/gossipd/gossip_store_wiregen.h
18+
WIRE_GOSSIP_STORE_PRIVATE_CHANNEL = 4104
19+
WIRE_GOSSIP_STORE_PRIVATE_UPDATE = 4102
20+
WIRE_GOSSIP_STORE_DELETE_CHAN = 4103
21+
WIRE_GOSSIP_STORE_ENDED = 4105
22+
23+
24+
class GossipStoreHeader(object):
25+
def __init__(self, buf: bytes):
26+
length, self.crc, self.timestamp = struct.unpack('>III', buf)
27+
self.deleted = (length & GOSSIP_STORE_LEN_DELETED_BIT) != 0
28+
self.length = (length & GOSSIP_STORE_LEN_MASK)
29+
30+
31+
# FIXME!
32+
class short_channel_id(int):
33+
pass
34+
35+
36+
class point(bytes):
37+
pass
38+
39+
40+
class GossmapChannel(object):
41+
def __init__(self,
42+
announce: bytes,
43+
announce_offset: int,
44+
scid,
45+
node1_id: point,
46+
node2_id: point,
47+
is_private: bool):
48+
self.announce = announce
49+
self.announce_offset = announce_offset
50+
self.is_private = is_private
51+
self.scid = scid
52+
self.node1_id = node1_id
53+
self.node2_id = node2_id
54+
self.updates: List[Optional[bytes]] = [None, None]
55+
self.updates_offset: List[Optional[int]] = [None, None]
56+
57+
58+
class GossmapNode(object):
59+
def __init__(self, node_id: point):
60+
self.announce = None
61+
self.announce_offset = None
62+
self.channels = []
63+
self.node_id = node_id
64+
65+
66+
class Gossmap(object):
67+
"""Class to represent the gossip map of the network"""
68+
def __init__(self, store_filename: str = "gossip_store"):
69+
self.store_filename = store_filename
70+
self.store_file = open(store_filename, "rb")
71+
self.store_buf = bytes()
72+
self.nodes: Dict[point, GossmapNode] = {}
73+
self.channels: Dict[short_channel_id, GossmapChannel] = {}
74+
version = self.store_file.read(1)
75+
if version[0] != GOSSIP_STORE_VERSION:
76+
raise ValueError("Invalid gossip store version {}".format(version))
77+
self.bytes_read = 1
78+
self.refresh()
79+
80+
def _new_channel(self,
81+
announce: bytes,
82+
announce_offset: int,
83+
scid: short_channel_id,
84+
node1_id: point,
85+
node2_id: point,
86+
is_private: bool):
87+
c = GossmapChannel(announce, announce_offset,
88+
scid, node1_id, node2_id,
89+
is_private)
90+
if node1_id not in self.nodes:
91+
self.nodes[node1_id] = GossmapNode(node1_id)
92+
if node2_id not in self.nodes:
93+
self.nodes[node2_id] = GossmapNode(node2_id)
94+
95+
self.channels[scid] = c
96+
self.nodes[node1_id].channels.append(c)
97+
self.nodes[node2_id].channels.append(c)
98+
99+
def _del_channel(self, scid: short_channel_id):
100+
c = self.channels[scid]
101+
n1 = self.nodes[c.node1_id]
102+
n2 = self.nodes[c.node2_id]
103+
n1.channels.remove(c)
104+
n2.channels.remove(c)
105+
# Beware self-channels n1-n1!
106+
if len(n1.channels) == 0 and n1 != n2:
107+
del self.nodes[c.node1_id]
108+
if len(n2.channels):
109+
del self.nodes[c.node2_id]
110+
111+
def add_channel(self, rec: bytes, off: int, is_private: bool):
112+
fields = channel_announcement.read(io.BytesIO(rec[2:]), {})
113+
self._new_channel(rec, off, fields['short_channel_id'],
114+
fields['node_id_1'], fields['node_id_2'],
115+
is_private)
116+
117+
def update_channel(self, rec: bytes, off: int):
118+
fields = channel_update.read(io.BytesIO(rec[2:]), {})
119+
direction = fields['message_flags'] & 1
120+
c = self.channels[fields['short_channel_id']]
121+
c.updates[direction] = rec
122+
c.updates_offset = off
123+
124+
def add_node_announcement(self, rec: bytes, off: int):
125+
fields = node_announcement.read(io.BytesIO(rec[2:]), {})
126+
self.nodes[fields['node_id']].announce = rec
127+
self.nodes[fields['node_id']].announce_offset = off
128+
129+
def reopen_store(self):
130+
assert False
131+
132+
def remove_channel_by_deletemsg(self, rec: bytes):
133+
scid, = struct.unpack(">Q", rec[2:])
134+
# It might have already been deleted when we skipped it.
135+
if scid in self.channels:
136+
self._del_channel(scid)
137+
138+
def _pull_bytes(self, length: int) -> bool:
139+
"""Pull bytes from file into our internal buffer"""
140+
if len(self.store_buf) < length:
141+
self.store_buf += self.store_file.read(length
142+
- len(self.store_buf))
143+
return len(self.store_buf) >= length
144+
145+
def _read_record(self) -> Optional[bytes]:
146+
"""If a whole record is not in the file, returns None.
147+
If deleted, returns empty."""
148+
if not self._pull_bytes(12):
149+
return None
150+
hdr = GossipStoreHeader(self.store_buf[:12])
151+
if not self._pull_bytes(12 + hdr.length):
152+
return None
153+
self.bytes_read += len(self.store_buf)
154+
ret = self.store_buf[12:]
155+
self.store_buf = bytes()
156+
if hdr.deleted:
157+
ret = bytes()
158+
return ret
159+
160+
def refresh(self):
161+
"""Catch up with any changes to the gossip store"""
162+
while True:
163+
off = self.bytes_read
164+
rec = self._read_record()
165+
# EOF?
166+
if rec is None:
167+
break
168+
# Deleted?
169+
if len(rec) == 0:
170+
continue
171+
172+
rectype, = struct.unpack(">H", rec[:2])
173+
if rectype == channel_announcement.number:
174+
self.add_channel(rec, off, False)
175+
elif rectype == WIRE_GOSSIP_STORE_PRIVATE_CHANNEL:
176+
self.add_channel(rec[2 + 8 + 2:], off + 2 + 8 + 2, True)
177+
elif rectype == channel_update.number:
178+
self.update_channel(rec, off)
179+
elif rectype == WIRE_GOSSIP_STORE_PRIVATE_UPDATE:
180+
self.update_channel(rec[2 + 2:], off + 2 + 2)
181+
elif rectype == WIRE_GOSSIP_STORE_DELETE_CHAN:
182+
self.remove_channel_by_deletemsg(rec)
183+
elif rectype == node_announcement.number:
184+
self.add_node_announcement(rec, off)
185+
elif rectype == WIRE_GOSSIP_STORE_ENDED:
186+
self.reopen_store()
187+
else:
188+
continue
Binary file not shown.
Binary file not shown.
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from pyln.client import Gossmap
2+
3+
import os.path
4+
import lzma
5+
import pytest
6+
7+
8+
def unxz_data_tmp(src, tmp_path, dst, wmode):
9+
fulldst = os.path.join(tmp_path, dst)
10+
with open(fulldst, wmode) as out:
11+
with lzma.open(os.path.join(os.path.dirname(__file__), "data", src), "rb")as f:
12+
out.write(f.read())
13+
return fulldst
14+
15+
16+
def test_gossmap(tmp_path):
17+
sfile = unxz_data_tmp("gossip_store-part1.xz", tmp_path, "gossip_store", "xb")
18+
g = Gossmap(sfile)
19+
20+
chans = len(g.channels)
21+
nodes = len(g.nodes)
22+
23+
g.refresh()
24+
assert chans == len(g.channels)
25+
assert nodes == len(g.nodes)
26+
27+
# Now append.
28+
unxz_data_tmp("gossip_store-part2.xz", tmp_path, "gossip_store", "ab")
29+
30+
g.refresh()
31+
32+
# It will notice the new ones.
33+
assert chans < len(g.channels)
34+
assert nodes < len(g.nodes)
35+
36+
# Whole load at the same time gives the same results.
37+
g2 = Gossmap(sfile)
38+
assert set(g.channels.keys()) == set(g2.channels.keys())
39+
assert set(g.nodes.keys()) == set(g2.nodes.keys())

0 commit comments

Comments
 (0)