blockchainkit 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- blockchainkit/__init__.py +12 -0
- blockchainkit/_validation.py +44 -0
- blockchainkit/consensus/__init__.py +66 -0
- blockchainkit/consensus/core/__init__.py +25 -0
- blockchainkit/consensus/core/base.py +97 -0
- blockchainkit/consensus/systems/__init__.py +46 -0
- blockchainkit/consensus/systems/byzantine.py +91 -0
- blockchainkit/consensus/systems/catch_up.py +60 -0
- blockchainkit/consensus/systems/difficulty.py +74 -0
- blockchainkit/consensus/systems/finality.py +92 -0
- blockchainkit/consensus/systems/fork_choice.py +44 -0
- blockchainkit/consensus/systems/pbft.py +78 -0
- blockchainkit/consensus/systems/pos.py +53 -0
- blockchainkit/consensus/systems/pow.py +58 -0
- blockchainkit/consensus/systems/pricing.py +50 -0
- blockchainkit/consensus/systems/randomized.py +114 -0
- blockchainkit/consensus/systems/selfish.py +81 -0
- blockchainkit/consensus/systems/sortition.py +48 -0
- blockchainkit/consensus/systems/stake_games.py +44 -0
- blockchainkit/consensus/systems/synchrony.py +49 -0
- blockchainkit/consensus/visualizers/__init__.py +9 -0
- blockchainkit/consensus/visualizers/plots.py +80 -0
- blockchainkit/constants.py +66 -0
- blockchainkit/crypto/__init__.py +157 -0
- blockchainkit/crypto/core/__init__.py +21 -0
- blockchainkit/crypto/core/base.py +109 -0
- blockchainkit/crypto/systems/__init__.py +137 -0
- blockchainkit/crypto/systems/asymmetric.py +136 -0
- blockchainkit/crypto/systems/commitments.py +93 -0
- blockchainkit/crypto/systems/curves.py +205 -0
- blockchainkit/crypto/systems/discrete_log.py +156 -0
- blockchainkit/crypto/systems/hashing.py +95 -0
- blockchainkit/crypto/systems/lamport.py +65 -0
- blockchainkit/crypto/systems/mac.py +34 -0
- blockchainkit/crypto/systems/merkle_damgard.py +139 -0
- blockchainkit/crypto/systems/multisig.py +98 -0
- blockchainkit/crypto/systems/one_time_pad.py +42 -0
- blockchainkit/crypto/systems/puzzles.py +96 -0
- blockchainkit/crypto/systems/sharing.py +159 -0
- blockchainkit/crypto/systems/signatures.py +232 -0
- blockchainkit/crypto/utils/__init__.py +5 -0
- blockchainkit/crypto/utils/primes.py +39 -0
- blockchainkit/crypto/visualizers/__init__.py +9 -0
- blockchainkit/crypto/visualizers/plots.py +88 -0
- blockchainkit/network/__init__.py +70 -0
- blockchainkit/network/core/__init__.py +21 -0
- blockchainkit/network/core/base.py +149 -0
- blockchainkit/network/systems/__init__.py +54 -0
- blockchainkit/network/systems/addresses.py +126 -0
- blockchainkit/network/systems/broadcast.py +124 -0
- blockchainkit/network/systems/clocks.py +149 -0
- blockchainkit/network/systems/epidemics.py +111 -0
- blockchainkit/network/systems/gossip.py +201 -0
- blockchainkit/network/systems/kademlia.py +147 -0
- blockchainkit/network/systems/privacy.py +98 -0
- blockchainkit/network/systems/propagation.py +60 -0
- blockchainkit/network/systems/relay.py +123 -0
- blockchainkit/network/systems/replication.py +122 -0
- blockchainkit/network/systems/topology.py +238 -0
- blockchainkit/network/visualizers/__init__.py +14 -0
- blockchainkit/network/visualizers/plots.py +177 -0
- blockchainkit/py.typed +0 -0
- blockchainkit/structures/__init__.py +61 -0
- blockchainkit/structures/core/__init__.py +21 -0
- blockchainkit/structures/core/base.py +100 -0
- blockchainkit/structures/systems/__init__.py +45 -0
- blockchainkit/structures/systems/block.py +144 -0
- blockchainkit/structures/systems/bloom.py +81 -0
- blockchainkit/structures/systems/chain.py +131 -0
- blockchainkit/structures/systems/hash_chain.py +35 -0
- blockchainkit/structures/systems/headers.py +54 -0
- blockchainkit/structures/systems/ledger.py +87 -0
- blockchainkit/structures/systems/merkle.py +273 -0
- blockchainkit/structures/systems/mmr.py +112 -0
- blockchainkit/structures/systems/sparse_merkle.py +139 -0
- blockchainkit/structures/systems/transaction.py +116 -0
- blockchainkit/structures/systems/utxo.py +156 -0
- blockchainkit/structures/utils/__init__.py +6 -0
- blockchainkit/structures/utils/accounts.py +13 -0
- blockchainkit/structures/utils/encoding.py +11 -0
- blockchainkit/structures/visualizers/__init__.py +13 -0
- blockchainkit/structures/visualizers/plots.py +154 -0
- blockchainkit/vm/__init__.py +60 -0
- blockchainkit/vm/core/__init__.py +25 -0
- blockchainkit/vm/core/base.py +171 -0
- blockchainkit/vm/systems/__init__.py +40 -0
- blockchainkit/vm/systems/assembler.py +80 -0
- blockchainkit/vm/systems/expressions.py +91 -0
- blockchainkit/vm/systems/programs.py +143 -0
- blockchainkit/vm/systems/reentrancy.py +70 -0
- blockchainkit/vm/systems/script.py +268 -0
- blockchainkit/vm/systems/stack_machine.py +239 -0
- blockchainkit/vm/systems/turing.py +111 -0
- blockchainkit/vm/systems/verifier.py +81 -0
- blockchainkit/vm/visualizers/__init__.py +9 -0
- blockchainkit/vm/visualizers/plots.py +97 -0
- blockchainkit-0.2.0.dist-info/METADATA +195 -0
- blockchainkit-0.2.0.dist-info/RECORD +101 -0
- blockchainkit-0.2.0.dist-info/WHEEL +5 -0
- blockchainkit-0.2.0.dist-info/licenses/LICENSE +21 -0
- blockchainkit-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Deterministic discrete-event gossip; no sockets, wall clock, or background threads."""
|
|
2
|
+
|
|
3
|
+
import heapq
|
|
4
|
+
from collections.abc import Callable, Iterable
|
|
5
|
+
from random import Random
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from blockchainkit._validation import integer
|
|
9
|
+
from blockchainkit._validation import seed as check_seed
|
|
10
|
+
from blockchainkit.crypto import sha256
|
|
11
|
+
from blockchainkit.network.core.base import Delivery
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from blockchainkit.network.systems.topology import Graph
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SimulatedNetwork:
|
|
18
|
+
"""An undirected peer graph with seeded link delays and duplicate suppression.
|
|
19
|
+
|
|
20
|
+
Parameters
|
|
21
|
+
----------
|
|
22
|
+
peers : iterable of str
|
|
23
|
+
Unique nonempty peer names. Initially no links exist.
|
|
24
|
+
seed : int
|
|
25
|
+
Private PRNG seed; construction does not modify global randomness.
|
|
26
|
+
on_receive : callable, optional
|
|
27
|
+
Callback for each first delivery, including the originating peer.
|
|
28
|
+
Return False to reject that payload at that peer and stop forwarding.
|
|
29
|
+
Returning None or True accepts it. Rejections remain seen.
|
|
30
|
+
|
|
31
|
+
Notes
|
|
32
|
+
-----
|
|
33
|
+
Disconnecting a link drops its in-flight messages. Reconnecting does not
|
|
34
|
+
automatically synchronize old data: call ``broadcast`` again. Latency is
|
|
35
|
+
sampled at send time, inclusive of both endpoints, and is at least one tick.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
peers: Iterable[str],
|
|
41
|
+
*,
|
|
42
|
+
seed: int = 0,
|
|
43
|
+
on_receive: Callable[[Delivery], bool | None] | None = None,
|
|
44
|
+
) -> None:
|
|
45
|
+
check_seed(seed)
|
|
46
|
+
names = tuple(peers)
|
|
47
|
+
if not names or any(not isinstance(name, str) or not name for name in names):
|
|
48
|
+
raise ValueError("provide nonempty peer names")
|
|
49
|
+
if len(set(names)) != len(names):
|
|
50
|
+
raise ValueError("peer names must be unique")
|
|
51
|
+
self._links: dict[tuple[str, str], tuple[int, int, int]] = {}
|
|
52
|
+
self._seen: dict[str, set[bytes]] = {name: set() for name in sorted(names)}
|
|
53
|
+
self._accepted: dict[str, set[bytes]] = {name: set() for name in sorted(names)}
|
|
54
|
+
self._queue: list[tuple[int, int, str, str, bytes, int]] = []
|
|
55
|
+
self._deliveries: list[Delivery] = []
|
|
56
|
+
self._random = Random(seed)
|
|
57
|
+
self._callback = on_receive
|
|
58
|
+
self._time = 0
|
|
59
|
+
self._serial = 0
|
|
60
|
+
self._generation = 0
|
|
61
|
+
self._sent = 0
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def from_graph(
|
|
65
|
+
cls,
|
|
66
|
+
graph: "Graph",
|
|
67
|
+
*,
|
|
68
|
+
latency: tuple[int, int] = (1, 1),
|
|
69
|
+
seed: int = 0,
|
|
70
|
+
on_receive: Callable[[Delivery], bool | None] | None = None,
|
|
71
|
+
) -> "SimulatedNetwork":
|
|
72
|
+
"""Build a network whose peers ``"0"``, ``"1"``, ... are linked like ``graph``.
|
|
73
|
+
|
|
74
|
+
>>> from blockchainkit.network import SimulatedNetwork, ring_lattice
|
|
75
|
+
>>> SimulatedNetwork.from_graph(ring_lattice(8, 2))
|
|
76
|
+
SimulatedNetwork(peers=8, links=8, time=0, pending=0)
|
|
77
|
+
"""
|
|
78
|
+
network = cls((str(node) for node in range(graph.n)), seed=seed, on_receive=on_receive)
|
|
79
|
+
for left, right in graph.edges:
|
|
80
|
+
network.connect(str(left), str(right), latency=latency)
|
|
81
|
+
return network
|
|
82
|
+
|
|
83
|
+
def __repr__(self) -> str:
|
|
84
|
+
return (
|
|
85
|
+
f"SimulatedNetwork(peers={len(self._seen)}, links={len(self._links)}, "
|
|
86
|
+
f"time={self._time}, pending={len(self._queue)})"
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def time(self) -> int:
|
|
91
|
+
"""Current simulated tick."""
|
|
92
|
+
return self._time
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def deliveries(self) -> tuple[Delivery, ...]:
|
|
96
|
+
"""Immutable snapshot of accepted first deliveries."""
|
|
97
|
+
return tuple(self._deliveries)
|
|
98
|
+
|
|
99
|
+
@property
|
|
100
|
+
def messages_sent(self) -> int:
|
|
101
|
+
"""Messages handed to links so far, including duplicates that receivers discard."""
|
|
102
|
+
return self._sent
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def pending(self) -> int:
|
|
106
|
+
"""Number of queued events, including events invalidated by disconnection."""
|
|
107
|
+
return len(self._queue)
|
|
108
|
+
|
|
109
|
+
def _key(self, left: str, right: str) -> tuple[str, str]:
|
|
110
|
+
if left not in self._seen or right not in self._seen or left == right:
|
|
111
|
+
raise ValueError("link endpoints must be distinct known peers")
|
|
112
|
+
return (left, right) if left < right else (right, left)
|
|
113
|
+
|
|
114
|
+
def connect(self, left: str, right: str, *, latency: tuple[int, int] = (1, 1)) -> None:
|
|
115
|
+
"""Create or replace a link with inclusive integer latency bounds."""
|
|
116
|
+
key = self._key(left, right)
|
|
117
|
+
low, high = latency
|
|
118
|
+
integer(low, "minimum latency", 1)
|
|
119
|
+
integer(high, "maximum latency", low)
|
|
120
|
+
self._generation += 1
|
|
121
|
+
self._links[key] = (low, high, self._generation)
|
|
122
|
+
|
|
123
|
+
def disconnect(self, left: str, right: str) -> None:
|
|
124
|
+
"""Remove a link, invalidating all in-flight events sent on that link."""
|
|
125
|
+
self._links.pop(self._key(left, right), None)
|
|
126
|
+
|
|
127
|
+
def _forward(self, sender: str, payload: bytes, *, exclude: str | None = None) -> None:
|
|
128
|
+
for recipient in self._seen:
|
|
129
|
+
if recipient in (sender, exclude):
|
|
130
|
+
continue
|
|
131
|
+
link = self._links.get(self._key(sender, recipient))
|
|
132
|
+
if link is not None:
|
|
133
|
+
low, high, generation = link
|
|
134
|
+
self._serial += 1
|
|
135
|
+
self._sent += 1
|
|
136
|
+
heapq.heappush(
|
|
137
|
+
self._queue,
|
|
138
|
+
(
|
|
139
|
+
self.time + self._random.randint(low, high),
|
|
140
|
+
self._serial,
|
|
141
|
+
sender,
|
|
142
|
+
recipient,
|
|
143
|
+
payload,
|
|
144
|
+
generation,
|
|
145
|
+
),
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
def _receive(self, sender: str, recipient: str, payload: bytes) -> bool:
|
|
149
|
+
digest = sha256(payload)
|
|
150
|
+
if digest in self._seen[recipient]:
|
|
151
|
+
return False
|
|
152
|
+
# Mark the payload seen before the callback runs, so a callback that
|
|
153
|
+
# rebroadcasts it cannot recurse; undo the mark if the callback fails.
|
|
154
|
+
self._seen[recipient].add(digest)
|
|
155
|
+
delivery = Delivery(self.time, sender, recipient, payload)
|
|
156
|
+
try:
|
|
157
|
+
verdict = None if self._callback is None else self._callback(delivery)
|
|
158
|
+
except BaseException:
|
|
159
|
+
self._seen[recipient].discard(digest)
|
|
160
|
+
raise
|
|
161
|
+
if verdict is False:
|
|
162
|
+
return False
|
|
163
|
+
self._accepted[recipient].add(digest)
|
|
164
|
+
self._deliveries.append(delivery)
|
|
165
|
+
self._forward(recipient, payload, exclude=sender)
|
|
166
|
+
return True
|
|
167
|
+
|
|
168
|
+
def broadcast(self, sender: str, payload: bytes) -> None:
|
|
169
|
+
"""Originate or retransmit bytes to neighbors; receivers suppress duplicates."""
|
|
170
|
+
if sender not in self._seen:
|
|
171
|
+
raise ValueError("unknown sender")
|
|
172
|
+
if not isinstance(payload, bytes):
|
|
173
|
+
raise TypeError("payload must be bytes")
|
|
174
|
+
if sha256(payload) in self._seen[sender]:
|
|
175
|
+
if sha256(payload) in self._accepted[sender]:
|
|
176
|
+
self._forward(sender, payload)
|
|
177
|
+
else:
|
|
178
|
+
self._receive(sender, sender, payload)
|
|
179
|
+
|
|
180
|
+
def run(self, *, until: int | None = None, max_events: int = 100_000) -> int:
|
|
181
|
+
"""Process queued events up to a time/event bound; return events processed.
|
|
182
|
+
|
|
183
|
+
With until set, the clock advances to that tick if the event budget
|
|
184
|
+
was not exhausted. Events after until remain queued for a later run.
|
|
185
|
+
"""
|
|
186
|
+
integer(max_events, "max_events", 1)
|
|
187
|
+
if until is not None:
|
|
188
|
+
integer(until, "until", self.time)
|
|
189
|
+
processed = 0
|
|
190
|
+
while self._queue and processed < max_events:
|
|
191
|
+
if until is not None and self._queue[0][0] > until:
|
|
192
|
+
break
|
|
193
|
+
time, _, sender, recipient, payload, generation = heapq.heappop(self._queue)
|
|
194
|
+
self._time = time
|
|
195
|
+
processed += 1
|
|
196
|
+
link = self._links.get(self._key(sender, recipient))
|
|
197
|
+
if link is not None and link[2] == generation:
|
|
198
|
+
self._receive(sender, recipient, payload)
|
|
199
|
+
if until is not None and (not self._queue or self._queue[0][0] > until):
|
|
200
|
+
self._time = until
|
|
201
|
+
return processed
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Kademlia routing by XOR distance (Maymounkov and Mazières 2002).
|
|
2
|
+
|
|
3
|
+
Every node has a ``bits``-bit identifier, and the distance between two
|
|
4
|
+
identifiers is their bitwise XOR read as an integer. A node sorts the nodes
|
|
5
|
+
it knows into *k-buckets*: bucket ``i`` holds up to ``k`` nodes whose
|
|
6
|
+
distance lies in ``[2**i, 2**(i+1))``, the nodes whose identifier first
|
|
7
|
+
differs from its own at bit ``i``. Knowing a few nodes at every scale lets a
|
|
8
|
+
lookup halve the remaining distance at each hop, so it takes about
|
|
9
|
+
``log2 n`` hops among ``n`` nodes.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from collections.abc import Iterable
|
|
13
|
+
from random import Random
|
|
14
|
+
|
|
15
|
+
from blockchainkit._validation import integer
|
|
16
|
+
from blockchainkit._validation import seed as check_seed
|
|
17
|
+
from blockchainkit.crypto.systems.hashing import sha256
|
|
18
|
+
from blockchainkit.network.core.base import LookupResult
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def xor_distance(a: int, b: int) -> int:
|
|
22
|
+
"""Kademlia's distance: ``a XOR b``. It is symmetric and zero only for ``a == b``.
|
|
23
|
+
|
|
24
|
+
>>> from blockchainkit.network import xor_distance
|
|
25
|
+
>>> xor_distance(0b1010, 0b0110)
|
|
26
|
+
12
|
|
27
|
+
"""
|
|
28
|
+
integer(a, "a")
|
|
29
|
+
integer(b, "b")
|
|
30
|
+
return a ^ b
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def node_id(key: bytes, bits: int) -> int:
|
|
34
|
+
"""Derive a ``bits``-bit identifier from a public key: the top bits of its SHA-256.
|
|
35
|
+
|
|
36
|
+
Assigning identifiers by hash means a node cannot simply pick a position
|
|
37
|
+
in the identifier space; it has to search for keys. See the Sybil
|
|
38
|
+
experiment.
|
|
39
|
+
|
|
40
|
+
>>> from blockchainkit.network import node_id
|
|
41
|
+
>>> node_id(b"alice", 8) < 2**8
|
|
42
|
+
True
|
|
43
|
+
"""
|
|
44
|
+
if not isinstance(key, bytes):
|
|
45
|
+
raise TypeError("key must be bytes")
|
|
46
|
+
integer(bits, "bits", 1)
|
|
47
|
+
if bits > 256:
|
|
48
|
+
raise ValueError("SHA-256 provides at most 256 bits")
|
|
49
|
+
return int.from_bytes(sha256(key), "big") >> (256 - bits)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class KademliaNetwork:
|
|
53
|
+
"""Nodes with idealized k-bucket routing tables.
|
|
54
|
+
|
|
55
|
+
Parameters
|
|
56
|
+
----------
|
|
57
|
+
ids : iterable of int
|
|
58
|
+
Distinct node identifiers in ``[0, 2**bits)``.
|
|
59
|
+
bits : int
|
|
60
|
+
Identifier length.
|
|
61
|
+
k : int
|
|
62
|
+
Bucket capacity. Each bucket is filled with up to ``k`` nodes drawn
|
|
63
|
+
uniformly from all nodes at that distance scale.
|
|
64
|
+
seed : int
|
|
65
|
+
Seed for filling the buckets.
|
|
66
|
+
|
|
67
|
+
Notes
|
|
68
|
+
-----
|
|
69
|
+
Real Kademlia nodes fill their buckets gradually from the traffic they
|
|
70
|
+
see and prefer long-lived contacts. Here every table is filled at once
|
|
71
|
+
from global knowledge, which isolates the routing geometry.
|
|
72
|
+
|
|
73
|
+
Examples
|
|
74
|
+
--------
|
|
75
|
+
>>> from blockchainkit.network import KademliaNetwork
|
|
76
|
+
>>> net = KademliaNetwork([0b000, 0b011, 0b100, 0b110], bits=3, k=1)
|
|
77
|
+
>>> net.lookup(0b000, 0b111).path
|
|
78
|
+
(0, 6)
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
def __init__(self, ids: Iterable[int], *, bits: int, k: int = 8, seed: int = 0) -> None:
|
|
82
|
+
integer(bits, "bits", 1)
|
|
83
|
+
integer(k, "k", 1)
|
|
84
|
+
check_seed(seed)
|
|
85
|
+
nodes = tuple(sorted(ids))
|
|
86
|
+
if not nodes:
|
|
87
|
+
raise ValueError("provide at least one node")
|
|
88
|
+
for node in nodes:
|
|
89
|
+
integer(node, "node id")
|
|
90
|
+
if node >= 2**bits:
|
|
91
|
+
raise ValueError(f"node ids must be below 2**{bits}")
|
|
92
|
+
if len(set(nodes)) != len(nodes):
|
|
93
|
+
raise ValueError("node ids must be distinct")
|
|
94
|
+
self._bits, self._k, self._ids = bits, k, nodes
|
|
95
|
+
random = Random(seed)
|
|
96
|
+
self._tables: dict[int, tuple[tuple[int, ...], ...]] = {}
|
|
97
|
+
for node in nodes:
|
|
98
|
+
scales: list[list[int]] = [[] for _ in range(bits)]
|
|
99
|
+
for other in nodes:
|
|
100
|
+
if other != node:
|
|
101
|
+
scales[(node ^ other).bit_length() - 1].append(other)
|
|
102
|
+
self._tables[node] = tuple(
|
|
103
|
+
tuple(sorted(random.sample(scale, min(k, len(scale))))) for scale in scales
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
def __repr__(self) -> str:
|
|
107
|
+
return f"KademliaNetwork(nodes={len(self._ids)}, bits={self._bits}, k={self._k})"
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def ids(self) -> tuple[int, ...]:
|
|
111
|
+
"""Node identifiers in increasing order."""
|
|
112
|
+
return self._ids
|
|
113
|
+
|
|
114
|
+
def buckets(self, node: int) -> tuple[tuple[int, ...], ...]:
|
|
115
|
+
"""The k-buckets of ``node``; bucket ``i`` holds contacts at distance [2**i, 2**(i+1))."""
|
|
116
|
+
if node not in self._tables:
|
|
117
|
+
raise ValueError("unknown node")
|
|
118
|
+
return self._tables[node]
|
|
119
|
+
|
|
120
|
+
def closest(self, target: int, count: int) -> tuple[int, ...]:
|
|
121
|
+
"""The ``count`` nodes closest to ``target`` by XOR distance (global truth).
|
|
122
|
+
|
|
123
|
+
In Kademlia these nodes store the value with key ``target``.
|
|
124
|
+
"""
|
|
125
|
+
integer(target, "target")
|
|
126
|
+
integer(count, "count", 1)
|
|
127
|
+
return tuple(sorted(self._ids, key=lambda node: node ^ target)[:count])
|
|
128
|
+
|
|
129
|
+
def lookup(self, source: int, target: int) -> LookupResult:
|
|
130
|
+
"""Route greedily from ``source`` toward ``target``.
|
|
131
|
+
|
|
132
|
+
At each hop the current node forwards to the contact in its buckets
|
|
133
|
+
closest to ``target``, while that is closer than itself. If the
|
|
134
|
+
current node differs at bit ``i`` from the true closest node, its
|
|
135
|
+
bucket ``i`` is nonempty and every contact there is closer to the
|
|
136
|
+
target, so the route always ends at the closest node.
|
|
137
|
+
"""
|
|
138
|
+
integer(target, "target")
|
|
139
|
+
current = source
|
|
140
|
+
path = [current]
|
|
141
|
+
while True:
|
|
142
|
+
contacts = [c for bucket in self.buckets(current) for c in bucket]
|
|
143
|
+
best = min(contacts, key=lambda c: c ^ target, default=current)
|
|
144
|
+
if best ^ target >= current ^ target:
|
|
145
|
+
return LookupResult(tuple(path))
|
|
146
|
+
current = best
|
|
147
|
+
path.append(current)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Transaction-origin privacy: diffusion versus Dandelion (Bojja Venkatakrishnan,
|
|
2
|
+
Fanti and Viswanath 2017).
|
|
3
|
+
|
|
4
|
+
When a peer broadcasts its transaction, it is usually the first to send it.
|
|
5
|
+
*Spy* peers that connect widely record who first sent them each
|
|
6
|
+
transaction and guess that peer is the origin: the *first-spy estimator*.
|
|
7
|
+
Dandelion first passes the transaction along a random path (the *stem*),
|
|
8
|
+
each hop continuing with probability ``q``, and only then diffuses it. The
|
|
9
|
+
spies then see the end of the stem, far from the origin.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import heapq
|
|
13
|
+
from collections.abc import Iterable
|
|
14
|
+
from random import Random
|
|
15
|
+
|
|
16
|
+
from blockchainkit._validation import integer, probability
|
|
17
|
+
from blockchainkit._validation import seed as check_seed
|
|
18
|
+
from blockchainkit.network.systems.topology import Graph
|
|
19
|
+
|
|
20
|
+
MODES = ("diffusion", "dandelion")
|
|
21
|
+
"""tuple of str: The broadcast strategies compared by :func:`first_spy_precision`."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _first_spy_guess(
|
|
25
|
+
graph: Graph, spies: frozenset[int], start: int, previous: int, random: Random
|
|
26
|
+
) -> int | None:
|
|
27
|
+
"""Diffuse from ``start`` with exponential link delays; return the first spy's sender.
|
|
28
|
+
|
|
29
|
+
``previous`` is the peer that handed the transaction to ``start``
|
|
30
|
+
(``start`` itself at the origin). Returns None if no spy is reachable.
|
|
31
|
+
"""
|
|
32
|
+
if start in spies:
|
|
33
|
+
return previous
|
|
34
|
+
heard = {start}
|
|
35
|
+
queue = [(random.expovariate(1.0), start, neighbor) for neighbor in graph.neighbors(start)]
|
|
36
|
+
heapq.heapify(queue)
|
|
37
|
+
while queue:
|
|
38
|
+
time, sender, peer = heapq.heappop(queue)
|
|
39
|
+
if peer in heard:
|
|
40
|
+
continue
|
|
41
|
+
heard.add(peer)
|
|
42
|
+
if peer in spies:
|
|
43
|
+
return sender
|
|
44
|
+
for neighbor in graph.neighbors(peer):
|
|
45
|
+
if neighbor not in heard:
|
|
46
|
+
heapq.heappush(queue, (time + random.expovariate(1.0), peer, neighbor))
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def first_spy_precision(
|
|
51
|
+
graph: Graph,
|
|
52
|
+
spies: Iterable[int],
|
|
53
|
+
*,
|
|
54
|
+
mode: str = "diffusion",
|
|
55
|
+
trials: int = 200,
|
|
56
|
+
stem_probability: float = 0.9,
|
|
57
|
+
seed: int = 0,
|
|
58
|
+
) -> float:
|
|
59
|
+
"""Fraction of broadcasts whose origin the first-spy estimator identifies.
|
|
60
|
+
|
|
61
|
+
Each trial picks an honest origin uniformly at random.
|
|
62
|
+
|
|
63
|
+
* ``"diffusion"``: the origin sends to all neighbors, and each peer
|
|
64
|
+
forwards on first receipt, with independent Exp(1) delay per message.
|
|
65
|
+
* ``"dandelion"``: the transaction first walks to a random neighbor;
|
|
66
|
+
after each hop it continues the stem with probability
|
|
67
|
+
``stem_probability`` and otherwise diffuses from where it is. A spy on
|
|
68
|
+
the stem guesses the peer that handed it the transaction.
|
|
69
|
+
|
|
70
|
+
The original Dandelion routes stems over a dedicated line-shaped
|
|
71
|
+
anonymity graph; here the stem is a random walk on ``graph`` itself.
|
|
72
|
+
|
|
73
|
+
Examples
|
|
74
|
+
--------
|
|
75
|
+
>>> from blockchainkit.network import complete_graph, first_spy_precision
|
|
76
|
+
>>> first_spy_precision(complete_graph(2), {1}, trials=10)
|
|
77
|
+
1.0
|
|
78
|
+
"""
|
|
79
|
+
spy_set = frozenset(spies)
|
|
80
|
+
honest = [node for node in range(graph.n) if node not in spy_set]
|
|
81
|
+
if not spy_set or not honest or not spy_set <= set(range(graph.n)):
|
|
82
|
+
raise ValueError("spies must be a nonempty proper subset of the peers")
|
|
83
|
+
if mode not in MODES:
|
|
84
|
+
raise ValueError(f"mode must be one of {MODES}")
|
|
85
|
+
integer(trials, "trials", 1)
|
|
86
|
+
probability(stem_probability, "stem_probability")
|
|
87
|
+
check_seed(seed)
|
|
88
|
+
random = Random(seed)
|
|
89
|
+
hits = 0
|
|
90
|
+
for _ in range(trials):
|
|
91
|
+
origin = previous = current = random.choice(honest)
|
|
92
|
+
if mode == "dandelion":
|
|
93
|
+
while graph.neighbors(current) and current not in spy_set:
|
|
94
|
+
previous, current = current, random.choice(graph.neighbors(current))
|
|
95
|
+
if random.random() >= stem_probability:
|
|
96
|
+
break
|
|
97
|
+
hits += _first_spy_guess(graph, spy_set, current, previous, random) == origin
|
|
98
|
+
return hits / trials
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Propagation delay and forks (Decker and Wattenhofer 2013).
|
|
2
|
+
|
|
3
|
+
A block takes time to reach the other miners. Until it arrives, they keep
|
|
4
|
+
mining on the old tip, and if one of them finds a block in that window the
|
|
5
|
+
chain forks. Block discovery is a Poisson process, so with propagation delay
|
|
6
|
+
``tau`` and mean block interval ``T`` the chance that a block is followed by a
|
|
7
|
+
competitor within ``tau`` is ``1 - exp(-tau / T)``. Shorter intervals or
|
|
8
|
+
slower relay mean more forks, wasted work, and an easier time for attackers.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from math import exp
|
|
12
|
+
from random import Random
|
|
13
|
+
|
|
14
|
+
from blockchainkit._validation import integer, positive
|
|
15
|
+
from blockchainkit._validation import seed as check_seed
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def fork_rate(delay: float, interval: float) -> float:
|
|
19
|
+
"""Probability that a competing block appears before a block has propagated.
|
|
20
|
+
|
|
21
|
+
Parameters
|
|
22
|
+
----------
|
|
23
|
+
delay : float
|
|
24
|
+
Time for a block to reach the other miners.
|
|
25
|
+
interval : float
|
|
26
|
+
Mean time between blocks, in the same unit.
|
|
27
|
+
|
|
28
|
+
Examples
|
|
29
|
+
--------
|
|
30
|
+
>>> from blockchainkit.network import fork_rate
|
|
31
|
+
>>> round(fork_rate(12.6, 600), 4)
|
|
32
|
+
0.0208
|
|
33
|
+
"""
|
|
34
|
+
if isinstance(delay, bool) or not isinstance(delay, (int, float)):
|
|
35
|
+
raise TypeError("delay must be a real number >= 0")
|
|
36
|
+
if not 0 <= delay < float("inf"):
|
|
37
|
+
raise ValueError("delay must be >= 0 and finite")
|
|
38
|
+
positive(interval, "interval")
|
|
39
|
+
return 1 - exp(-delay / interval)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def simulate_fork_rate(
|
|
43
|
+
delay: float, interval: float, *, blocks: int = 10_000, seed: int = 0
|
|
44
|
+
) -> float:
|
|
45
|
+
"""Measure the fork rate on a simulated chain of Poisson block discoveries.
|
|
46
|
+
|
|
47
|
+
Draws exponential gaps with mean ``interval`` between successive blocks
|
|
48
|
+
and counts the blocks whose successor was found less than ``delay``
|
|
49
|
+
later, when its miner could not yet have seen them.
|
|
50
|
+
|
|
51
|
+
>>> from blockchainkit.network import simulate_fork_rate
|
|
52
|
+
>>> simulate_fork_rate(0.0, 600, blocks=100)
|
|
53
|
+
0.0
|
|
54
|
+
"""
|
|
55
|
+
fork_rate(delay, interval) # Validates both.
|
|
56
|
+
integer(blocks, "blocks", 1)
|
|
57
|
+
check_seed(seed)
|
|
58
|
+
random = Random(seed)
|
|
59
|
+
forks = sum(1 for _ in range(blocks) if random.expovariate(1 / interval) < delay)
|
|
60
|
+
return forks / blocks
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Block relay: flooding, inv/getdata announcements (Bitcoin 2009), compact blocks (BIP 152, 2016).
|
|
2
|
+
|
|
3
|
+
Flooding sends the full block over every link. Bitcoin's original protocol
|
|
4
|
+
instead *announces* the block's hash with an ``inv`` message and sends the
|
|
5
|
+
block only to peers that ask with ``getdata``, so each peer downloads it
|
|
6
|
+
once, at the price of a round trip per hop. Compact blocks go further: most
|
|
7
|
+
transactions are already in the receiver's mempool, so the sender lists
|
|
8
|
+
short transaction IDs and the receiver rebuilds the block locally.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from collections.abc import Iterable, Sequence
|
|
12
|
+
|
|
13
|
+
from blockchainkit._validation import integer
|
|
14
|
+
from blockchainkit.constants import COMPACT_BLOCK_DOMAIN
|
|
15
|
+
from blockchainkit.crypto.systems.hashing import sha256
|
|
16
|
+
from blockchainkit.network.core.base import CompactBlockResult, RelayCost
|
|
17
|
+
from blockchainkit.network.systems.topology import Graph
|
|
18
|
+
|
|
19
|
+
HEADER_SIZE = 80
|
|
20
|
+
"""int: Bytes in a block header."""
|
|
21
|
+
|
|
22
|
+
ANNOUNCE_SIZE = 61
|
|
23
|
+
"""int: Bytes in an ``inv`` or ``getdata`` for one item: a 24-byte message
|
|
24
|
+
header, a one-byte count, and a 36-byte inventory vector."""
|
|
25
|
+
|
|
26
|
+
MODES = ("flood", "announce")
|
|
27
|
+
"""tuple of str: The relay strategies compared by :func:`relay_cost`."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def relay_cost(graph: Graph, *, size: int, mode: str = "flood", source: int = 0) -> RelayCost:
|
|
31
|
+
"""Count the traffic to relay a ``size``-byte block from ``source`` to its component.
|
|
32
|
+
|
|
33
|
+
Every peer, on first receiving the block, passes it on to each neighbor
|
|
34
|
+
except the one it came from, so ``2E - (n - 1)`` messages cross the
|
|
35
|
+
links of a connected graph with E edges and n peers.
|
|
36
|
+
|
|
37
|
+
* ``"flood"``: each such message carries the whole block, and the block
|
|
38
|
+
reaches a peer at distance d after d link latencies.
|
|
39
|
+
* ``"announce"``: each such message is an ``inv``; a peer that lacks the
|
|
40
|
+
block answers its first ``inv`` with a ``getdata`` and receives the
|
|
41
|
+
block. Every peer downloads the block once, but each hop costs three
|
|
42
|
+
latencies.
|
|
43
|
+
|
|
44
|
+
Examples
|
|
45
|
+
--------
|
|
46
|
+
>>> from blockchainkit.network import complete_graph, relay_cost
|
|
47
|
+
>>> relay_cost(complete_graph(10), size=1_000_000).bytes
|
|
48
|
+
81000000
|
|
49
|
+
"""
|
|
50
|
+
integer(size, "size", 1)
|
|
51
|
+
if mode not in MODES:
|
|
52
|
+
raise ValueError(f"mode must be one of {MODES}")
|
|
53
|
+
distances = graph.distances(source)
|
|
54
|
+
reached = [node for node, d in enumerate(distances) if d is not None]
|
|
55
|
+
forwards = sum(graph.degrees()[node] for node in reached) - (len(reached) - 1)
|
|
56
|
+
depth = max(d for d in distances if d is not None)
|
|
57
|
+
if mode == "flood":
|
|
58
|
+
return RelayCost(forwards, forwards * size, depth)
|
|
59
|
+
downloads = len(reached) - 1
|
|
60
|
+
return RelayCost(
|
|
61
|
+
forwards + 2 * downloads,
|
|
62
|
+
forwards * ANNOUNCE_SIZE + downloads * (ANNOUNCE_SIZE + size),
|
|
63
|
+
3 * depth,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def short_id(transaction: bytes, nonce: int, size: int = 6) -> bytes:
|
|
68
|
+
"""A ``size``-byte transaction ID salted by the block's ``nonce``.
|
|
69
|
+
|
|
70
|
+
Salting with a per-block nonce stops an attacker from crafting
|
|
71
|
+
transactions whose short IDs collide in every block. BIP 152 uses
|
|
72
|
+
SipHash keyed by the header and nonce; this teaching version uses
|
|
73
|
+
SHA-256.
|
|
74
|
+
|
|
75
|
+
>>> from blockchainkit.network import short_id
|
|
76
|
+
>>> len(short_id(b"tx", nonce=7))
|
|
77
|
+
6
|
|
78
|
+
"""
|
|
79
|
+
if not isinstance(transaction, bytes):
|
|
80
|
+
raise TypeError("transaction must be bytes")
|
|
81
|
+
integer(nonce, "nonce")
|
|
82
|
+
integer(size, "size", 1)
|
|
83
|
+
return sha256(COMPACT_BLOCK_DOMAIN + nonce.to_bytes(8, "big") + sha256(transaction))[:size]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def compact_block_relay(
|
|
87
|
+
block: Sequence[bytes],
|
|
88
|
+
mempool: Iterable[bytes],
|
|
89
|
+
*,
|
|
90
|
+
nonce: int = 0,
|
|
91
|
+
short_id_size: int = 6,
|
|
92
|
+
) -> CompactBlockResult:
|
|
93
|
+
"""Compare sending ``block`` in full with sending it as a compact block.
|
|
94
|
+
|
|
95
|
+
The compact form costs the header, an 8-byte nonce, and one short ID
|
|
96
|
+
per transaction. The receiver matches short IDs against its mempool.
|
|
97
|
+
A transaction with no match, or with an ambiguous or wrong match, must
|
|
98
|
+
be requested in a second round trip: a request of ``ANNOUNCE_SIZE``
|
|
99
|
+
bytes plus 2 bytes per index, then the transactions themselves.
|
|
100
|
+
|
|
101
|
+
Examples
|
|
102
|
+
--------
|
|
103
|
+
>>> from blockchainkit.network import compact_block_relay
|
|
104
|
+
>>> block = [bytes([i]) * 250 for i in range(100)]
|
|
105
|
+
>>> result = compact_block_relay(block, block[:90])
|
|
106
|
+
>>> result.missing, result.round_trips, result.full_bytes
|
|
107
|
+
(10, 2, 25080)
|
|
108
|
+
"""
|
|
109
|
+
integer(short_id_size, "short_id_size", 1)
|
|
110
|
+
pool: dict[bytes, list[bytes]] = {}
|
|
111
|
+
for transaction in set(mempool):
|
|
112
|
+
pool.setdefault(short_id(transaction, nonce, short_id_size), []).append(transaction)
|
|
113
|
+
missing = [
|
|
114
|
+
transaction
|
|
115
|
+
for transaction in block
|
|
116
|
+
if pool.get(short_id(transaction, nonce, short_id_size)) != [transaction]
|
|
117
|
+
]
|
|
118
|
+
compact = HEADER_SIZE + 8 + short_id_size * len(block)
|
|
119
|
+
if missing:
|
|
120
|
+
compact += ANNOUNCE_SIZE + 2 * len(missing) + sum(len(tx) for tx in missing)
|
|
121
|
+
return CompactBlockResult(
|
|
122
|
+
HEADER_SIZE + sum(len(tx) for tx in block), compact, len(missing), 2 if missing else 1
|
|
123
|
+
)
|