Skip to content

Commit 5a5d239

Browse files
committed
Added tool to make a graph of the UTXOs tracked by the manager
1 parent 04afe8d commit 5a5d239

File tree

4 files changed

+188
-2
lines changed

4 files changed

+188
-2
lines changed

.gitignore

+2
Original file line numberDiff line numberDiff line change
@@ -160,3 +160,5 @@ cython_debug/
160160
#.idea/
161161

162162
examples/**/.cli-history
163+
164+
tests/graphs/**

pyproject.toml

+3
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,7 @@ license = { file = "LICENSE" }
1616
dependencies = []
1717

1818
[tool.poetry.dev-dependencies]
19+
bokeh = "^3.3.4"
20+
networkx = "^3.2.1"
21+
numpy = "^1.26.4"
1922
pytest = "^6.2.5"

test_utils/utxograph.py

+163
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
from typing import Dict
2+
import networkx as nx
3+
from bokeh.io import output_file, save
4+
from bokeh.models import (Arrow, Segment, NormalHead, BoxZoomTool, HoverTool, Plot, Range1d,
5+
ResetTool, Rect, Text, ColumnDataSource, TapTool, CustomJS, Div)
6+
from bokeh.palettes import Spectral4
7+
from bokeh.layouts import column
8+
9+
from matt.manager import ContractInstance, ContractInstanceStatus, ContractManager
10+
11+
NODE_WIDTH = 0.2
12+
NODE_HEIGHT = 0.15
13+
14+
15+
def instance_info(inst: ContractInstance) -> str:
16+
return f"""{inst.contract}
17+
Data: {inst.data_expanded}
18+
"""
19+
20+
21+
def create_utxo_graph(manager: ContractManager, filename: str):
22+
23+
# Function to calculate the intersection point
24+
def calculate_intersection(sx, sy, ex, ey, width, height):
25+
dx = ex - sx
26+
dy = ey - sy
27+
28+
if dx == 0: # Vertical line
29+
return (ex, sy + height / 2 * (-1 if ey < sy else 1))
30+
31+
slope = dy / dx
32+
if abs(slope) * width / 2 < height / 2:
33+
# Intersects with left/right side
34+
x_offset = width / 2 * (-1 if ex < sx else 1)
35+
y_offset = x_offset * slope
36+
else:
37+
# Intersects with top/bottom
38+
y_offset = height / 2 * (-1 if ey < sy else 1)
39+
x_offset = y_offset / slope
40+
41+
return (ex - x_offset, ey - y_offset)
42+
43+
# Prepare Data
44+
45+
G = nx.Graph()
46+
47+
node_to_instance: Dict[int, ContractInstance] = {}
48+
49+
for i, inst in enumerate(manager.instances):
50+
if inst.status in [ContractInstanceStatus.FUNDED, ContractInstanceStatus.SPENT]:
51+
G.add_node(i, label=str(inst.contract))
52+
node_to_instance[i] = inst
53+
54+
for i, inst in enumerate(manager.instances):
55+
if inst.next is not None:
56+
for next_inst in inst.next:
57+
i_next = manager.instances.index(next_inst)
58+
G.add_edge(i, i_next)
59+
60+
# Layout
61+
# TODO: we should find a layout that respects the "transactions", grouping together
62+
# inputs of the same transaction, and positioning UTXOs left-to-right in a
63+
# topological order
64+
pos = nx.spring_layout(G)
65+
66+
min_x = min(v[0] for v in pos.values())
67+
max_x = max(v[0] for v in pos.values())
68+
min_y = min(v[1] for v in pos.values())
69+
max_y = max(v[1] for v in pos.values())
70+
71+
# Convert position to the format bokeh uses
72+
x, y = zip(*pos.values())
73+
74+
node_names = [node_to_instance[i].contract.__class__.__name__ for i in G.nodes()]
75+
node_labels = [str(node_to_instance[i].contract) for i in G.nodes()]
76+
node_infos = [instance_info(node_to_instance[i]) for i in G.nodes()]
77+
78+
source = ColumnDataSource({
79+
'x': x,
80+
'y': y,
81+
'node_names': node_names,
82+
'node_labels': node_labels,
83+
'node_infos': node_infos,
84+
})
85+
86+
# Show with Bokeh
87+
plot = Plot(width=1024, height=768, x_range=Range1d(min_x - NODE_WIDTH*2, max_x + NODE_WIDTH*2),
88+
y_range=Range1d(min_y - NODE_HEIGHT*2, max_y + NODE_HEIGHT*2))
89+
90+
plot.title.text = "Contracts graph"
91+
92+
node_hover_tool = HoverTool(tooltips=[("index", "@node_labels")])
93+
94+
plot.add_tools(node_hover_tool, BoxZoomTool(), ResetTool())
95+
96+
# Nodes as rounded rectangles
97+
node_glyph = Rect(width=NODE_WIDTH, height=NODE_HEIGHT,
98+
fill_color=Spectral4[0], line_color=None, fill_alpha=0.7)
99+
plot.add_glyph(source, node_glyph)
100+
101+
# Labels for the nodes
102+
labels = Text(x='x', y='y', text='node_names',
103+
text_baseline="middle", text_align="center")
104+
plot.add_glyph(source, labels)
105+
106+
# Create a Div to display information
107+
info_div = Div(width=200, height=100, sizing_mode="fixed",
108+
text="Click on a node")
109+
110+
# CustomJS callback to update the Div content
111+
callback = CustomJS(args=dict(info_div=info_div, nodes_source=source), code="""
112+
const info = info_div;
113+
const selected_node_indices = nodes_source.selected.indices;
114+
115+
if (selected_node_indices.length > 0) {
116+
const node_index = selected_node_indices[0];
117+
const node_info = nodes_source.data.node_infos[node_index];
118+
info.text = node_info;
119+
} else {
120+
info.text = "Click on a node";
121+
}
122+
""")
123+
124+
for start_node, end_node in G.edges():
125+
sx, sy = pos[start_node]
126+
ex, ey = pos[end_node]
127+
128+
ix_start, iy_start = calculate_intersection(
129+
sx, sy, ex, ey, NODE_WIDTH, NODE_HEIGHT)
130+
ix_end, iy_end = calculate_intersection(
131+
ex, ey, sx, sy, NODE_WIDTH, NODE_HEIGHT)
132+
133+
start_instance = node_to_instance[start_node]
134+
clause_args = f"{start_instance.spending_clause}"
135+
136+
edge_source = ColumnDataSource(data={
137+
'x0': [ix_start],
138+
'y0': [iy_start],
139+
'x1': [ix_end],
140+
'y1': [iy_end],
141+
'edge_label': [f"{clause_args}"]
142+
})
143+
144+
segment_glyph = Segment(x0='x0', y0='y0', x1='x1',
145+
y1='y1', line_color="black", line_width=2)
146+
segment_renderer = plot.add_glyph(edge_source, segment_glyph)
147+
148+
arrow_glyph = Arrow(end=NormalHead(fill_color="black", size=10),
149+
x_start='x1', y_start='y1', x_end='x0', y_end='y0',
150+
source=edge_source, line_color="black")
151+
plot.add_layout(arrow_glyph)
152+
153+
edge_hover = HoverTool(renderers=[segment_renderer], tooltips=[
154+
("Clause: ", "@edge_label")])
155+
plot.add_tools(edge_hover)
156+
157+
tap_tool = TapTool(callback=callback)
158+
plot.add_tools(tap_tool)
159+
160+
layout = column(plot, info_div)
161+
162+
output_file(filename)
163+
save(layout)

tests/conftest.py

+20-2
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import pytest
22

33
import os
4+
from pathlib import Path
45

56
from matt.btctools.auth_proxy import AuthServiceProxy
67
from matt.manager import ContractManager
8+
from test_utils.utxograph import create_utxo_graph
79

810

911
rpc_url = "http://%s:%s@%s:%s" % (
@@ -14,6 +16,15 @@
1416
)
1517

1618

19+
def pytest_addoption(parser):
20+
parser.addoption("--utxo_graph", action="store_true")
21+
22+
23+
@pytest.fixture
24+
def utxo_graph(request: pytest.FixtureRequest):
25+
return request.config.getoption("--utxo_graph", False)
26+
27+
1728
@pytest.fixture(scope="session")
1829
def rpc():
1930
return AuthServiceProxy(rpc_url)
@@ -25,8 +36,15 @@ def rpc_test_wallet():
2536

2637

2738
@pytest.fixture
28-
def manager(rpc):
29-
return ContractManager(rpc, mine_automatically=True, poll_interval=0.01)
39+
def manager(rpc, request: pytest.FixtureRequest, utxo_graph: bool):
40+
manager = ContractManager(rpc, mine_automatically=True, poll_interval=0.01)
41+
yield manager
42+
43+
if utxo_graph:
44+
# Create the "tests/graphs" directory if it doesn't exist
45+
path = Path("tests/graphs")
46+
path.mkdir(exist_ok=True)
47+
create_utxo_graph(manager, f"tests/graphs/{request.node.name}.html")
3048

3149

3250
class TestReport:

0 commit comments

Comments
 (0)