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.
Files changed (101) hide show
  1. blockchainkit/__init__.py +12 -0
  2. blockchainkit/_validation.py +44 -0
  3. blockchainkit/consensus/__init__.py +66 -0
  4. blockchainkit/consensus/core/__init__.py +25 -0
  5. blockchainkit/consensus/core/base.py +97 -0
  6. blockchainkit/consensus/systems/__init__.py +46 -0
  7. blockchainkit/consensus/systems/byzantine.py +91 -0
  8. blockchainkit/consensus/systems/catch_up.py +60 -0
  9. blockchainkit/consensus/systems/difficulty.py +74 -0
  10. blockchainkit/consensus/systems/finality.py +92 -0
  11. blockchainkit/consensus/systems/fork_choice.py +44 -0
  12. blockchainkit/consensus/systems/pbft.py +78 -0
  13. blockchainkit/consensus/systems/pos.py +53 -0
  14. blockchainkit/consensus/systems/pow.py +58 -0
  15. blockchainkit/consensus/systems/pricing.py +50 -0
  16. blockchainkit/consensus/systems/randomized.py +114 -0
  17. blockchainkit/consensus/systems/selfish.py +81 -0
  18. blockchainkit/consensus/systems/sortition.py +48 -0
  19. blockchainkit/consensus/systems/stake_games.py +44 -0
  20. blockchainkit/consensus/systems/synchrony.py +49 -0
  21. blockchainkit/consensus/visualizers/__init__.py +9 -0
  22. blockchainkit/consensus/visualizers/plots.py +80 -0
  23. blockchainkit/constants.py +66 -0
  24. blockchainkit/crypto/__init__.py +157 -0
  25. blockchainkit/crypto/core/__init__.py +21 -0
  26. blockchainkit/crypto/core/base.py +109 -0
  27. blockchainkit/crypto/systems/__init__.py +137 -0
  28. blockchainkit/crypto/systems/asymmetric.py +136 -0
  29. blockchainkit/crypto/systems/commitments.py +93 -0
  30. blockchainkit/crypto/systems/curves.py +205 -0
  31. blockchainkit/crypto/systems/discrete_log.py +156 -0
  32. blockchainkit/crypto/systems/hashing.py +95 -0
  33. blockchainkit/crypto/systems/lamport.py +65 -0
  34. blockchainkit/crypto/systems/mac.py +34 -0
  35. blockchainkit/crypto/systems/merkle_damgard.py +139 -0
  36. blockchainkit/crypto/systems/multisig.py +98 -0
  37. blockchainkit/crypto/systems/one_time_pad.py +42 -0
  38. blockchainkit/crypto/systems/puzzles.py +96 -0
  39. blockchainkit/crypto/systems/sharing.py +159 -0
  40. blockchainkit/crypto/systems/signatures.py +232 -0
  41. blockchainkit/crypto/utils/__init__.py +5 -0
  42. blockchainkit/crypto/utils/primes.py +39 -0
  43. blockchainkit/crypto/visualizers/__init__.py +9 -0
  44. blockchainkit/crypto/visualizers/plots.py +88 -0
  45. blockchainkit/network/__init__.py +70 -0
  46. blockchainkit/network/core/__init__.py +21 -0
  47. blockchainkit/network/core/base.py +149 -0
  48. blockchainkit/network/systems/__init__.py +54 -0
  49. blockchainkit/network/systems/addresses.py +126 -0
  50. blockchainkit/network/systems/broadcast.py +124 -0
  51. blockchainkit/network/systems/clocks.py +149 -0
  52. blockchainkit/network/systems/epidemics.py +111 -0
  53. blockchainkit/network/systems/gossip.py +201 -0
  54. blockchainkit/network/systems/kademlia.py +147 -0
  55. blockchainkit/network/systems/privacy.py +98 -0
  56. blockchainkit/network/systems/propagation.py +60 -0
  57. blockchainkit/network/systems/relay.py +123 -0
  58. blockchainkit/network/systems/replication.py +122 -0
  59. blockchainkit/network/systems/topology.py +238 -0
  60. blockchainkit/network/visualizers/__init__.py +14 -0
  61. blockchainkit/network/visualizers/plots.py +177 -0
  62. blockchainkit/py.typed +0 -0
  63. blockchainkit/structures/__init__.py +61 -0
  64. blockchainkit/structures/core/__init__.py +21 -0
  65. blockchainkit/structures/core/base.py +100 -0
  66. blockchainkit/structures/systems/__init__.py +45 -0
  67. blockchainkit/structures/systems/block.py +144 -0
  68. blockchainkit/structures/systems/bloom.py +81 -0
  69. blockchainkit/structures/systems/chain.py +131 -0
  70. blockchainkit/structures/systems/hash_chain.py +35 -0
  71. blockchainkit/structures/systems/headers.py +54 -0
  72. blockchainkit/structures/systems/ledger.py +87 -0
  73. blockchainkit/structures/systems/merkle.py +273 -0
  74. blockchainkit/structures/systems/mmr.py +112 -0
  75. blockchainkit/structures/systems/sparse_merkle.py +139 -0
  76. blockchainkit/structures/systems/transaction.py +116 -0
  77. blockchainkit/structures/systems/utxo.py +156 -0
  78. blockchainkit/structures/utils/__init__.py +6 -0
  79. blockchainkit/structures/utils/accounts.py +13 -0
  80. blockchainkit/structures/utils/encoding.py +11 -0
  81. blockchainkit/structures/visualizers/__init__.py +13 -0
  82. blockchainkit/structures/visualizers/plots.py +154 -0
  83. blockchainkit/vm/__init__.py +60 -0
  84. blockchainkit/vm/core/__init__.py +25 -0
  85. blockchainkit/vm/core/base.py +171 -0
  86. blockchainkit/vm/systems/__init__.py +40 -0
  87. blockchainkit/vm/systems/assembler.py +80 -0
  88. blockchainkit/vm/systems/expressions.py +91 -0
  89. blockchainkit/vm/systems/programs.py +143 -0
  90. blockchainkit/vm/systems/reentrancy.py +70 -0
  91. blockchainkit/vm/systems/script.py +268 -0
  92. blockchainkit/vm/systems/stack_machine.py +239 -0
  93. blockchainkit/vm/systems/turing.py +111 -0
  94. blockchainkit/vm/systems/verifier.py +81 -0
  95. blockchainkit/vm/visualizers/__init__.py +9 -0
  96. blockchainkit/vm/visualizers/plots.py +97 -0
  97. blockchainkit-0.2.0.dist-info/METADATA +195 -0
  98. blockchainkit-0.2.0.dist-info/RECORD +101 -0
  99. blockchainkit-0.2.0.dist-info/WHEEL +5 -0
  100. blockchainkit-0.2.0.dist-info/licenses/LICENSE +21 -0
  101. 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
+ )