Skip to content

Commit ae6d406

Browse files
authored
Merge pull request #13 from Merkleize/graph
Manager's UTXOs graph tool in test suite
2 parents 28a7b16 + d5cb904 commit ae6d406

File tree

7 files changed

+205
-16
lines changed

7 files changed

+205
-16
lines changed

.github/workflows/run-tests.yml

+3-3
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,13 @@ jobs:
2424
uses: actions/setup-python@v2
2525
with:
2626
python-version: '3.10'
27+
- name: Clone
28+
uses: actions/checkout@v2
2729
- name: Install pip and pytest
2830
run: |
2931
apt-get update
3032
apt-get install -y python3-pip
31-
pip install -U pytest
32-
- name: Clone
33-
uses: actions/checkout@v2
33+
pip install -r requirements-dev.txt
3434
- name: Install pymatt
3535
run: |
3636
pip install .

.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/**

matt/manager.py

+13-8
Original file line numberDiff line numberDiff line change
@@ -96,14 +96,13 @@ def __init__(self, contract: Union[StandardP2TR, StandardAugmentedP2TR]):
9696
self.outpoint: Optional[COutPoint] = None
9797
self.funding_tx: Optional[CTransaction] = None
9898

99+
# The following fields are filled when the instance is spent
99100
self.spending_tx: Optional[CTransaction] = None
100-
self.spending_vin = None
101-
102-
self.spending_clause = None
103-
self.spending_args = None
104-
105-
# Once spent, the list of ContractInstances produced
106-
self.next = None
101+
self.spending_vin: Optional[int] = None
102+
self.spending_clause: Optional[str] = None
103+
self.spending_args: Optional[dict] = None
104+
# the new instances produced by spending this instance
105+
self.next: Optional[List[ContractInstance]] = None
107106

108107
def is_augm(self) -> bool:
109108
"""
@@ -580,13 +579,16 @@ def wait_for_spend(self, instances: Union[ContractInstance, List[ContractInstanc
580579
# and add them to the manager if they are standard
581580
if isinstance(next_outputs, CTransaction):
582581
# For now, we assume CTV clauses are terminal;
583-
# this might be generalized in the future
582+
# this might be generalized in the future to support tracking
583+
# known output contracts in a CTV template
584584
pass
585585
else:
586+
next_instances: List[ContractInstance] = []
586587
for clause_output in next_outputs:
587588
output_index = vin if clause_output.n == -1 else clause_output.n
588589

589590
if output_index in out_contracts:
591+
next_instances.append(out_contracts[output_index])
590592
continue # output already specified by another input
591593

592594
out_contract = clause_output.next_contract
@@ -610,6 +612,9 @@ def wait_for_spend(self, instances: Union[ContractInstance, List[ContractInstanc
610612

611613
out_contracts[output_index] = new_instance
612614

615+
next_instances.append(new_instance)
616+
instance.next = next_instances
617+
613618
result = list(out_contracts.values())
614619
for instance in result:
615620
self.add_instance(instance)

pyproject.toml

-3
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,3 @@ requires-python = ">=3.7"
1414
keywords = ["covenant", "smart contracts", "bitcoin"]
1515
license = { file = "LICENSE" }
1616
dependencies = []
17-
18-
[tool.poetry.dev-dependencies]
19-
pytest = "^6.2.5"

requirements-dev.txt

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
bokeh>=3.1.0,<4
2+
networkx>=3.1,<4
3+
numpy>=1.24,<2
4+
pytest>=6.2,<7

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)