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,78 @@
|
|
|
1
|
+
"""Practical Byzantine Fault Tolerance (Castro and Liskov 1999): the normal-case round.
|
|
2
|
+
|
|
3
|
+
With n = 3f + 1 replicas, any two quorums of 2f + 1 overlap in at least f + 1
|
|
4
|
+
replicas, so at least one honest replica is in both. A replica *prepares* a
|
|
5
|
+
value after 2f matching prepare messages and *commits* it after 2f + 1
|
|
6
|
+
matching commits. Two honest replicas can then never commit different values
|
|
7
|
+
in one view, even if the leader equivocates, as long as at most f replicas
|
|
8
|
+
are faulty.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from collections.abc import Set
|
|
12
|
+
|
|
13
|
+
from blockchainkit._validation import integer
|
|
14
|
+
from blockchainkit.consensus.core.base import PBFTResult
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def quorum_size(replicas: int) -> int:
|
|
18
|
+
"""Return 2f + 1 for the largest f with n >= 3f + 1.
|
|
19
|
+
|
|
20
|
+
>>> from blockchainkit.consensus import quorum_size
|
|
21
|
+
>>> quorum_size(4), quorum_size(7)
|
|
22
|
+
(3, 5)
|
|
23
|
+
"""
|
|
24
|
+
integer(replicas, "replicas", 4)
|
|
25
|
+
return 2 * ((replicas - 1) // 3) + 1
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def pbft_round(
|
|
29
|
+
replicas: int, faulty: Set[int], value: str, *, equivocate: bool = False, other: str = "B"
|
|
30
|
+
) -> PBFTResult:
|
|
31
|
+
"""Run one PBFT pre-prepare / prepare / commit exchange with leader 0.
|
|
32
|
+
|
|
33
|
+
Faulty replicas send prepare and commit messages for *both* values to
|
|
34
|
+
everyone, the most confusing thing they can do. A faulty leader with
|
|
35
|
+
``equivocate=True`` pre-prepares ``value`` to the first half of the honest
|
|
36
|
+
replicas and ``other`` to the rest.
|
|
37
|
+
|
|
38
|
+
Parameters
|
|
39
|
+
----------
|
|
40
|
+
replicas : int
|
|
41
|
+
Number of replicas n, at least 4.
|
|
42
|
+
faulty : set of int
|
|
43
|
+
Faulty replica numbers. The protocol is sized for f = (n - 1) // 3;
|
|
44
|
+
pass more to see safety fail.
|
|
45
|
+
value, other : str
|
|
46
|
+
The leader's value, and the conflicting one an equivocating leader
|
|
47
|
+
also sends.
|
|
48
|
+
|
|
49
|
+
Returns
|
|
50
|
+
-------
|
|
51
|
+
PBFTResult
|
|
52
|
+
What each honest replica prepared and committed.
|
|
53
|
+
"""
|
|
54
|
+
f = (quorum_size(replicas) - 1) // 2
|
|
55
|
+
if any(r not in range(replicas) for r in faulty):
|
|
56
|
+
raise ValueError("faulty replicas must be replica numbers")
|
|
57
|
+
honest = [r for r in range(replicas) if r not in faulty]
|
|
58
|
+
faulty_backups = len(faulty - {0})
|
|
59
|
+
|
|
60
|
+
# 1. Pre-prepare: the leader (replica 0) proposes a value to each replica.
|
|
61
|
+
equivocating = 0 in faulty and equivocate
|
|
62
|
+
split = (len(honest) + 1) // 2
|
|
63
|
+
proposal = {r: other if equivocating and i >= split else value for i, r in enumerate(honest)}
|
|
64
|
+
|
|
65
|
+
# 2. Prepare: honest backups echo their proposal; faulty backups echo both values.
|
|
66
|
+
def prepares(v: str) -> int:
|
|
67
|
+
return sum(1 for r in honest if r != 0 and proposal[r] == v) + faulty_backups
|
|
68
|
+
|
|
69
|
+
prepared = {r: proposal[r] if prepares(proposal[r]) >= 2 * f else None for r in honest}
|
|
70
|
+
|
|
71
|
+
# 3. Commit: honest replicas commit what they prepared; faulty ones commit both.
|
|
72
|
+
def commits(v: str) -> int:
|
|
73
|
+
return sum(1 for r in honest if prepared[r] == v) + len(faulty)
|
|
74
|
+
|
|
75
|
+
committed = {
|
|
76
|
+
r: v if v is not None and commits(v) >= 2 * f + 1 else None for r, v in prepared.items()
|
|
77
|
+
}
|
|
78
|
+
return PBFTResult(prepared, committed)
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Stake-weighted proposer sampling, not a complete PoS consensus protocol."""
|
|
2
|
+
|
|
3
|
+
from bisect import bisect_right
|
|
4
|
+
from collections.abc import Mapping
|
|
5
|
+
from itertools import accumulate
|
|
6
|
+
from random import Random
|
|
7
|
+
|
|
8
|
+
from blockchainkit._validation import integer
|
|
9
|
+
from blockchainkit._validation import seed as check_seed
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class StakeSampler:
|
|
13
|
+
"""Select proposers with exact integer weights and reproducible randomness.
|
|
14
|
+
|
|
15
|
+
Parameters
|
|
16
|
+
----------
|
|
17
|
+
stakes : Mapping
|
|
18
|
+
Nonnegative weights with a positive total. Names are sorted so mapping
|
|
19
|
+
insertion order does not alter results.
|
|
20
|
+
seed : int
|
|
21
|
+
Simulation seed, not an unpredictable consensus randomness beacon.
|
|
22
|
+
|
|
23
|
+
Examples
|
|
24
|
+
--------
|
|
25
|
+
>>> from blockchainkit.consensus import StakeSampler
|
|
26
|
+
>>> StakeSampler({"alice": 1, "bob": 0}, seed=7).sample(3)
|
|
27
|
+
('alice', 'alice', 'alice')
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, stakes: Mapping[str, int], *, seed: int = 0) -> None:
|
|
31
|
+
check_seed(seed)
|
|
32
|
+
if not stakes or any(not isinstance(name, str) or not name for name in stakes):
|
|
33
|
+
raise ValueError("provide nonempty validator names")
|
|
34
|
+
for weight in stakes.values():
|
|
35
|
+
integer(weight, "stake")
|
|
36
|
+
self._stakes = tuple(sorted(stakes.items()))
|
|
37
|
+
self._total = sum(stakes.values())
|
|
38
|
+
if self._total == 0:
|
|
39
|
+
raise ValueError("total stake must be positive")
|
|
40
|
+
self._cumulative = tuple(accumulate(weight for _, weight in self._stakes))
|
|
41
|
+
self._random = Random(seed)
|
|
42
|
+
|
|
43
|
+
def choose(self) -> str:
|
|
44
|
+
"""Select one validator with probability stake / total stake."""
|
|
45
|
+
ticket = self._random.randrange(self._total)
|
|
46
|
+
# ticket < total guarantees an index inside the cumulative weights.
|
|
47
|
+
# bisect_right skips repeated boundaries from zero-weight validators.
|
|
48
|
+
return self._stakes[bisect_right(self._cumulative, ticket)][0]
|
|
49
|
+
|
|
50
|
+
def sample(self, rounds: int) -> tuple[str, ...]:
|
|
51
|
+
"""Return proposers for a bounded number of rounds."""
|
|
52
|
+
integer(rounds, "rounds")
|
|
53
|
+
return tuple(self.choose() for _ in range(rounds))
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Bounded proof-of-work experiments with explicit success probabilities."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import replace
|
|
4
|
+
from typing import TYPE_CHECKING
|
|
5
|
+
|
|
6
|
+
from blockchainkit._validation import integer
|
|
7
|
+
from blockchainkit.consensus.core.base import MiningResult
|
|
8
|
+
from blockchainkit.constants import UINT64_LIMIT
|
|
9
|
+
from blockchainkit.crypto.systems.hashing import sha256
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from blockchainkit.structures.systems.block import Block
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def target(difficulty: int) -> int:
|
|
16
|
+
"""Return the inclusive target for a leading-zero-bit difficulty.
|
|
17
|
+
|
|
18
|
+
>>> from blockchainkit.consensus import target
|
|
19
|
+
>>> target(0) == 2**256 - 1
|
|
20
|
+
True
|
|
21
|
+
"""
|
|
22
|
+
integer(difficulty, "difficulty")
|
|
23
|
+
if difficulty > 256:
|
|
24
|
+
raise ValueError("difficulty cannot exceed 256")
|
|
25
|
+
return (1 << (256 - difficulty)) - 1
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def expected_trials(difficulty: int) -> int:
|
|
29
|
+
"""Return 2**difficulty, assuming independent uniform 256-bit hashes."""
|
|
30
|
+
target(difficulty)
|
|
31
|
+
return 1 << difficulty
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def valid_pow(block: "Block") -> bool:
|
|
35
|
+
"""Check that the block hash, interpreted big-endian, is at most its target."""
|
|
36
|
+
return int.from_bytes(block.hash, "big") <= target(block.difficulty)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def mine(block: "Block", *, max_attempts: int = 100_000) -> MiningResult:
|
|
40
|
+
"""Search sequential nonces starting at block.nonce, with a strict bound.
|
|
41
|
+
|
|
42
|
+
Raises
|
|
43
|
+
------
|
|
44
|
+
TimeoutError
|
|
45
|
+
No solution was found within max_attempts (not evidence of no solution).
|
|
46
|
+
ValueError
|
|
47
|
+
The search would exceed the 64-bit nonce space.
|
|
48
|
+
"""
|
|
49
|
+
integer(max_attempts, "max_attempts", 1)
|
|
50
|
+
if block.nonce + max_attempts > UINT64_LIMIT:
|
|
51
|
+
raise ValueError("search exceeds the 64-bit nonce space")
|
|
52
|
+
limit = target(block.difficulty)
|
|
53
|
+
# Only the header changes between attempts; the Merkle root is computed once.
|
|
54
|
+
for attempt in range(max_attempts):
|
|
55
|
+
nonce = block.nonce + attempt
|
|
56
|
+
if int.from_bytes(sha256(block.header(nonce=nonce)), "big") <= limit:
|
|
57
|
+
return MiningResult(replace(block, nonce=nonce), attempt + 1)
|
|
58
|
+
raise TimeoutError(f"no solution in {max_attempts} attempts")
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Pricing via processing (Dwork and Naor 1992): make each message cost computation.
|
|
2
|
+
|
|
3
|
+
To fight junk mail, Dwork and Naor proposed that a sender compute a
|
|
4
|
+
*pricing function*, moderately hard to evaluate but easy to check, for each
|
|
5
|
+
message. One of their examples is a square root modulo a prime: computing
|
|
6
|
+
it takes an exponentiation, checking it takes one multiplication.
|
|
7
|
+
Back's Hashcash later used hash preimages for the same purpose, and
|
|
8
|
+
Bitcoin's proof of work descends from that.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from blockchainkit._validation import integer
|
|
12
|
+
from blockchainkit.consensus.core.base import SquareRootResult
|
|
13
|
+
from blockchainkit.crypto.utils.primes import is_prime
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def modular_square_root(value: int, prime: int) -> SquareRootResult:
|
|
17
|
+
"""Compute a square root modulo a prime ``p = 3 (mod 4)``, counting multiplications.
|
|
18
|
+
|
|
19
|
+
For such primes, ``value**((p + 1) / 4)`` is a square root of any
|
|
20
|
+
quadratic residue. Square-and-multiply needs about ``1.5 log2 p``
|
|
21
|
+
multiplications; checking the answer needs one.
|
|
22
|
+
|
|
23
|
+
Raises
|
|
24
|
+
------
|
|
25
|
+
ValueError
|
|
26
|
+
``prime`` is not a prime congruent to 3 modulo 4, or ``value`` has no
|
|
27
|
+
square root (it is not a quadratic residue).
|
|
28
|
+
|
|
29
|
+
Examples
|
|
30
|
+
--------
|
|
31
|
+
>>> from blockchainkit.consensus import modular_square_root
|
|
32
|
+
>>> result = modular_square_root(4, 7)
|
|
33
|
+
>>> result.root ** 2 % 7
|
|
34
|
+
4
|
|
35
|
+
"""
|
|
36
|
+
integer(value, "value")
|
|
37
|
+
integer(prime, "prime", 3)
|
|
38
|
+
if not is_prime(prime) or prime % 4 != 3:
|
|
39
|
+
raise ValueError("prime must be a prime congruent to 3 modulo 4")
|
|
40
|
+
exponent, base, root, multiplications = (prime + 1) // 4, value % prime, 1, 0
|
|
41
|
+
while exponent:
|
|
42
|
+
if exponent & 1:
|
|
43
|
+
root = root * base % prime
|
|
44
|
+
multiplications += 1
|
|
45
|
+
base = base * base % prime
|
|
46
|
+
multiplications += 1
|
|
47
|
+
exponent >>= 1
|
|
48
|
+
if root * root % prime != value % prime:
|
|
49
|
+
raise ValueError("value is not a quadratic residue modulo prime")
|
|
50
|
+
return SquareRootResult(root, multiplications)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Ben-Or's randomized consensus (1983), and the FLP impossibility (1985) it sidesteps.
|
|
2
|
+
|
|
3
|
+
Fischer, Lynch and Paterson proved that no *deterministic* protocol can
|
|
4
|
+
guarantee agreement in an asynchronous network if even one process may
|
|
5
|
+
crash: an adversary scheduling message delivery can keep it undecided
|
|
6
|
+
forever. Ben-Or's protocol escapes by tossing coins. In each round processes
|
|
7
|
+
exchange values, propose a strong majority if they see one, and adopt a
|
|
8
|
+
proposal or flip a coin. Whatever the schedule, the coins eventually line up.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from collections.abc import Callable, Sequence
|
|
12
|
+
from random import Random
|
|
13
|
+
|
|
14
|
+
from blockchainkit._validation import integer
|
|
15
|
+
from blockchainkit._validation import seed as check_seed
|
|
16
|
+
from blockchainkit.consensus.core.base import ConsensusRun
|
|
17
|
+
|
|
18
|
+
_UNDECIDED = -1
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _adversarial_pick(values: dict[int, int], quota: int, favor_mixed: bool) -> list[int]:
|
|
22
|
+
"""Choose ``quota`` senders that keep the receiver as undecided as possible."""
|
|
23
|
+
ones = [p for p, v in values.items() if v == 1]
|
|
24
|
+
zeros = [p for p, v in values.items() if v == 0]
|
|
25
|
+
others = [p for p, v in values.items() if v not in (0, 1)]
|
|
26
|
+
if favor_mixed: # Phase 1: alternate values so no strong majority appears.
|
|
27
|
+
order: list[int] = []
|
|
28
|
+
while zeros or ones:
|
|
29
|
+
for group in (zeros, ones):
|
|
30
|
+
if group:
|
|
31
|
+
order.append(group.pop())
|
|
32
|
+
return order[:quota]
|
|
33
|
+
return (others + zeros + ones)[:quota] # Phase 2: as few concrete proposals as possible.
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def ben_or(
|
|
37
|
+
initial: Sequence[int],
|
|
38
|
+
faults: int,
|
|
39
|
+
*,
|
|
40
|
+
coin: Callable[[int, int], int] | None = None,
|
|
41
|
+
adversarial: bool = True,
|
|
42
|
+
seed: int = 0,
|
|
43
|
+
max_rounds: int = 1000,
|
|
44
|
+
) -> ConsensusRun:
|
|
45
|
+
"""Run Ben-Or's binary consensus for crash faults (n > 2f).
|
|
46
|
+
|
|
47
|
+
Each process waits for ``n - f`` messages per phase, the most it can
|
|
48
|
+
wait for when f processes may have crashed; the scheduler chooses which.
|
|
49
|
+
|
|
50
|
+
Parameters
|
|
51
|
+
----------
|
|
52
|
+
initial : sequence of int
|
|
53
|
+
Each process's input bit.
|
|
54
|
+
faults : int
|
|
55
|
+
The number of crashes f the protocol is sized for.
|
|
56
|
+
coin : callable, optional
|
|
57
|
+
``coin(process, round) -> 0 or 1``. Defaults to a seeded fair coin.
|
|
58
|
+
Pass a deterministic function to see the FLP adversary win.
|
|
59
|
+
adversarial : bool
|
|
60
|
+
If True, the scheduler delivers the messages most likely to delay a
|
|
61
|
+
decision; otherwise a random ``n - f`` subset.
|
|
62
|
+
seed : int
|
|
63
|
+
Seed for the coin and the random scheduler.
|
|
64
|
+
max_rounds : int
|
|
65
|
+
Give up after this many rounds.
|
|
66
|
+
|
|
67
|
+
Returns
|
|
68
|
+
-------
|
|
69
|
+
ConsensusRun
|
|
70
|
+
Decisions so far, rounds run, and whether every process decided.
|
|
71
|
+
|
|
72
|
+
Examples
|
|
73
|
+
--------
|
|
74
|
+
>>> from blockchainkit.consensus import ben_or
|
|
75
|
+
>>> ben_or([1, 1, 1], faults=1).decisions
|
|
76
|
+
{0: 1, 1: 1, 2: 1}
|
|
77
|
+
"""
|
|
78
|
+
n = len(initial)
|
|
79
|
+
integer(faults, "faults")
|
|
80
|
+
integer(max_rounds, "max_rounds", 1)
|
|
81
|
+
check_seed(seed)
|
|
82
|
+
if n <= 2 * faults:
|
|
83
|
+
raise ValueError("Ben-Or needs n > 2f processes")
|
|
84
|
+
if any(v not in (0, 1) for v in initial):
|
|
85
|
+
raise ValueError("inputs must be bits")
|
|
86
|
+
rng = Random(seed)
|
|
87
|
+
flip = coin or (lambda process, round_: rng.getrandbits(1))
|
|
88
|
+
quota = n - faults
|
|
89
|
+
values = dict(enumerate(initial))
|
|
90
|
+
decisions: dict[int, int] = {}
|
|
91
|
+
for round_ in range(1, max_rounds + 1):
|
|
92
|
+
proposals = {}
|
|
93
|
+
for p in range(n):
|
|
94
|
+
heard = (
|
|
95
|
+
_adversarial_pick(dict(values), quota, True)
|
|
96
|
+
if adversarial
|
|
97
|
+
else rng.sample(range(n), quota)
|
|
98
|
+
)
|
|
99
|
+
counts = [sum(values[q] == bit for q in heard) for bit in (0, 1)]
|
|
100
|
+
strong = [bit for bit in (0, 1) if counts[bit] > n / 2]
|
|
101
|
+
proposals[p] = strong[0] if strong else _UNDECIDED
|
|
102
|
+
for p in range(n):
|
|
103
|
+
heard = (
|
|
104
|
+
_adversarial_pick(dict(proposals), quota, False)
|
|
105
|
+
if adversarial
|
|
106
|
+
else rng.sample(range(n), quota)
|
|
107
|
+
)
|
|
108
|
+
seen = [proposals[q] for q in heard if proposals[q] != _UNDECIDED]
|
|
109
|
+
if seen and len(seen) >= faults + 1:
|
|
110
|
+
decisions.setdefault(p, seen[0])
|
|
111
|
+
values[p] = seen[0] if seen else flip(p, round_)
|
|
112
|
+
if len(decisions) == n:
|
|
113
|
+
return ConsensusRun(decisions, round_, True)
|
|
114
|
+
return ConsensusRun(decisions, max_rounds, False)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Selfish mining (Eyal and Sirer 2014): majority is not enough.
|
|
2
|
+
|
|
3
|
+
A pool that withholds the blocks it finds, and publishes them only to
|
|
4
|
+
overtake or tie the honest chain, wastes the honest miners' work. Above a
|
|
5
|
+
threshold hashrate its share of the chain exceeds its share of the hashrate,
|
|
6
|
+
so honest miners gain by joining it. ``gamma`` is the fraction of honest
|
|
7
|
+
miners who build on the pool's block during a tie.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from random import Random
|
|
11
|
+
|
|
12
|
+
from blockchainkit._validation import integer
|
|
13
|
+
from blockchainkit._validation import seed as check_seed
|
|
14
|
+
from blockchainkit.consensus.core.base import SelfishMiningResult
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _check(alpha: float, gamma: float) -> None:
|
|
18
|
+
if not 0 < alpha < 0.5:
|
|
19
|
+
raise ValueError("alpha must be in (0, 0.5)")
|
|
20
|
+
if not 0 <= gamma <= 1:
|
|
21
|
+
raise ValueError("gamma must be in [0, 1]")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def selfish_mining_revenue(alpha: float, gamma: float) -> float:
|
|
25
|
+
"""Return the selfish pool's long-run share of blocks (Eyal and Sirer, eq. 8).
|
|
26
|
+
|
|
27
|
+
``R = (alpha (1-alpha)**2 (4 alpha + gamma (1 - 2 alpha)) - alpha**3)
|
|
28
|
+
/ (1 - alpha (1 + (2 - alpha) alpha))``
|
|
29
|
+
|
|
30
|
+
>>> from blockchainkit.consensus import selfish_mining_revenue
|
|
31
|
+
>>> round(selfish_mining_revenue(0.4, 0.0), 3)
|
|
32
|
+
0.484
|
|
33
|
+
"""
|
|
34
|
+
_check(alpha, gamma)
|
|
35
|
+
a = alpha
|
|
36
|
+
numerator = a * (1 - a) ** 2 * (4 * a + gamma * (1 - 2 * a)) - a**3
|
|
37
|
+
return float(numerator / (1 - a * (1 + (2 - a) * a)))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def selfish_mining_threshold(gamma: float) -> float:
|
|
41
|
+
"""Return the hashrate above which selfish mining pays: ``(1 - gamma) / (3 - 2 gamma)``."""
|
|
42
|
+
if not 0 <= gamma <= 1:
|
|
43
|
+
raise ValueError("gamma must be in [0, 1]")
|
|
44
|
+
return (1 - gamma) / (3 - 2 * gamma)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def simulate_selfish_mining(
|
|
48
|
+
alpha: float, gamma: float, *, blocks: int, seed: int = 0
|
|
49
|
+
) -> SelfishMiningResult:
|
|
50
|
+
"""Simulate the selfish-mining state machine (Eyal and Sirer, Algorithm 1).
|
|
51
|
+
|
|
52
|
+
Each event, the pool finds a block with probability alpha, otherwise the
|
|
53
|
+
honest network does. The state is the pool's private lead, plus a tie
|
|
54
|
+
state after the pool publishes to match an honest block.
|
|
55
|
+
"""
|
|
56
|
+
_check(alpha, gamma)
|
|
57
|
+
integer(blocks, "blocks", 1)
|
|
58
|
+
check_seed(seed)
|
|
59
|
+
rng = Random(seed)
|
|
60
|
+
lead, tie, selfish, honest = 0, False, 0, 0
|
|
61
|
+
for _ in range(blocks):
|
|
62
|
+
pool_found = rng.random() < alpha
|
|
63
|
+
if tie:
|
|
64
|
+
if pool_found:
|
|
65
|
+
selfish += 2 # The pool extends its own branch and wins both blocks.
|
|
66
|
+
elif rng.random() < gamma:
|
|
67
|
+
selfish, honest = selfish + 1, honest + 1 # Honest block on the pool's branch.
|
|
68
|
+
else:
|
|
69
|
+
honest += 2 # Honest block on the honest branch.
|
|
70
|
+
tie = False
|
|
71
|
+
elif pool_found:
|
|
72
|
+
lead += 1
|
|
73
|
+
elif lead == 0:
|
|
74
|
+
honest += 1
|
|
75
|
+
elif lead == 1:
|
|
76
|
+
lead, tie = 0, True # Publish and race.
|
|
77
|
+
elif lead == 2:
|
|
78
|
+
lead, selfish = 0, selfish + 2 # Publish both and orphan the honest block.
|
|
79
|
+
else:
|
|
80
|
+
lead, selfish = lead - 1, selfish + 1 # Reveal one block; stay ahead.
|
|
81
|
+
return SelfishMiningResult(selfish, honest, selfish / max(1, selfish + honest))
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Cryptographic sortition (Algorand 2017): secret, stake-weighted committee selection.
|
|
2
|
+
|
|
3
|
+
Algorand picks each round's committee by lottery. Every unit of stake is a
|
|
4
|
+
ticket; a user hashes a round seed with their private key (in Algorand a
|
|
5
|
+
verifiable random function, so others can check the result) and maps the
|
|
6
|
+
hash to a number of selected tickets that is Binomial(stake, tau / W).
|
|
7
|
+
Nobody, not even the user, knows who is on the committee before they speak,
|
|
8
|
+
and splitting stake across accounts does not change the expected seats.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from math import comb
|
|
12
|
+
|
|
13
|
+
from blockchainkit._validation import integer
|
|
14
|
+
from blockchainkit.constants import SORTITION_DOMAIN
|
|
15
|
+
from blockchainkit.crypto.systems.hashing import sha256
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def sortition(
|
|
19
|
+
secret: bytes, stake: int, total_stake: int, expected: int, *, round_seed: bytes
|
|
20
|
+
) -> int:
|
|
21
|
+
"""Return how many of a user's stake units are selected this round.
|
|
22
|
+
|
|
23
|
+
The hash of ``secret || round_seed``, read as a uniform number in [0, 1),
|
|
24
|
+
is located in the cumulative Binomial(stake, expected / total_stake)
|
|
25
|
+
distribution. Teaching simplification: a hash of the secret replaces
|
|
26
|
+
the verifiable random function, so others cannot verify the draw.
|
|
27
|
+
|
|
28
|
+
Examples
|
|
29
|
+
--------
|
|
30
|
+
>>> from blockchainkit.consensus import sortition
|
|
31
|
+
>>> sortition(b"my key", 0, 1000, 20, round_seed=b"r1")
|
|
32
|
+
0
|
|
33
|
+
"""
|
|
34
|
+
if not isinstance(secret, bytes) or not isinstance(round_seed, bytes):
|
|
35
|
+
raise TypeError("secret and round_seed must be bytes")
|
|
36
|
+
integer(stake, "stake")
|
|
37
|
+
integer(total_stake, "total_stake", 1)
|
|
38
|
+
integer(expected, "expected", 1)
|
|
39
|
+
if stake > total_stake or expected > total_stake:
|
|
40
|
+
raise ValueError("stake and expected committee size cannot exceed total stake")
|
|
41
|
+
draw = int.from_bytes(sha256(SORTITION_DOMAIN + secret + round_seed), "big") / 2**256
|
|
42
|
+
p = expected / total_stake
|
|
43
|
+
cumulative = 0.0
|
|
44
|
+
for j in range(stake):
|
|
45
|
+
cumulative += comb(stake, j) * p**j * (1 - p) ** (stake - j)
|
|
46
|
+
if draw < cumulative:
|
|
47
|
+
return j
|
|
48
|
+
return stake # The last bucket, which also absorbs floating-point rounding.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""The nothing-at-stake problem (Buterin 2014): voting on every fork is free.
|
|
2
|
+
|
|
3
|
+
A proof-of-work miner must split its hashrate between forks. A proof-of-stake
|
|
4
|
+
validator can sign blocks on every fork at no cost, and collect the reward
|
|
5
|
+
whichever fork wins. If everyone does, forks never resolve. The remedy,
|
|
6
|
+
first proposed in Buterin's Slasher, is a penalty: evidence of signing two
|
|
7
|
+
conflicting blocks destroys the validator's deposit.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def fork_voting_payoffs(
|
|
12
|
+
fork_a_probability: float, reward: float, penalty: float
|
|
13
|
+
) -> dict[str, float]:
|
|
14
|
+
"""Expected payoff of voting for fork A, fork B, or both.
|
|
15
|
+
|
|
16
|
+
Parameters
|
|
17
|
+
----------
|
|
18
|
+
fork_a_probability : float
|
|
19
|
+
Chance that fork A becomes canonical.
|
|
20
|
+
reward : float
|
|
21
|
+
Reward for having voted on the winning fork.
|
|
22
|
+
penalty : float
|
|
23
|
+
Deposit destroyed when a validator is caught voting on both (slashing).
|
|
24
|
+
|
|
25
|
+
Returns
|
|
26
|
+
-------
|
|
27
|
+
dict
|
|
28
|
+
Expected payoff of the strategies ``"A"``, ``"B"`` and ``"both"``.
|
|
29
|
+
|
|
30
|
+
Examples
|
|
31
|
+
--------
|
|
32
|
+
>>> from blockchainkit.consensus import fork_voting_payoffs
|
|
33
|
+
>>> fork_voting_payoffs(0.5, 1.0, 0.0)
|
|
34
|
+
{'A': 0.5, 'B': 0.5, 'both': 1.0}
|
|
35
|
+
"""
|
|
36
|
+
if not 0 <= fork_a_probability <= 1:
|
|
37
|
+
raise ValueError("fork_a_probability must be in [0, 1]")
|
|
38
|
+
if reward < 0 or penalty < 0:
|
|
39
|
+
raise ValueError("reward and penalty must be non-negative")
|
|
40
|
+
return {
|
|
41
|
+
"A": fork_a_probability * reward,
|
|
42
|
+
"B": (1 - fork_a_probability) * reward,
|
|
43
|
+
"both": reward - penalty,
|
|
44
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Partial synchrony (Dwork, Lynch and Stockmeyer 1988): progress after an unknown GST.
|
|
2
|
+
|
|
3
|
+
Real networks are neither synchronous (known delay bound) nor fully
|
|
4
|
+
asynchronous (FLP applies). Partial synchrony assumes a bound Delta holds
|
|
5
|
+
after some unknown Global Stabilization Time (GST). Protocols stay safe
|
|
6
|
+
always, and make progress once a leader's view lasts long enough: doubling
|
|
7
|
+
the timeout each view guarantees that eventually it exceeds Delta.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from blockchainkit._validation import integer
|
|
11
|
+
from blockchainkit.consensus.core.base import ViewChangeRun
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def view_changes(
|
|
15
|
+
gst: int, delta: int, base_timeout: int, *, growth: int = 2, max_views: int = 64
|
|
16
|
+
) -> ViewChangeRun:
|
|
17
|
+
"""Simulate leader views with growing timeouts until one makes progress.
|
|
18
|
+
|
|
19
|
+
Before GST the adversary delays the leader's message past every timeout;
|
|
20
|
+
from GST on it arrives after ``delta`` ticks. View v lasts
|
|
21
|
+
``base_timeout * growth**v`` and succeeds when it starts at or after GST
|
|
22
|
+
and its timeout is at least ``delta``.
|
|
23
|
+
|
|
24
|
+
Raises
|
|
25
|
+
------
|
|
26
|
+
TimeoutError
|
|
27
|
+
No view succeeded within ``max_views`` (for example, timeouts that
|
|
28
|
+
never grow past delta).
|
|
29
|
+
|
|
30
|
+
Examples
|
|
31
|
+
--------
|
|
32
|
+
>>> from blockchainkit.consensus import view_changes
|
|
33
|
+
>>> view_changes(gst=10, delta=5, base_timeout=1).decided_view
|
|
34
|
+
4
|
|
35
|
+
"""
|
|
36
|
+
integer(gst, "gst")
|
|
37
|
+
integer(delta, "delta", 1)
|
|
38
|
+
integer(base_timeout, "base_timeout", 1)
|
|
39
|
+
integer(growth, "growth", 1)
|
|
40
|
+
integer(max_views, "max_views", 1)
|
|
41
|
+
starts, timeouts, time = [], [], 0
|
|
42
|
+
for view in range(max_views):
|
|
43
|
+
timeout = base_timeout * growth**view
|
|
44
|
+
starts.append(time)
|
|
45
|
+
timeouts.append(timeout)
|
|
46
|
+
if time >= gst and delta <= timeout:
|
|
47
|
+
return ViewChangeRun(tuple(starts), tuple(timeouts), view, time + delta)
|
|
48
|
+
time += timeout
|
|
49
|
+
raise TimeoutError(f"no view made progress in {max_views} views")
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""Plotting helpers for blockchainkit.consensus.
|
|
2
|
+
|
|
3
|
+
Imports matplotlib, so ``import blockchainkit`` does not load this module;
|
|
4
|
+
import it explicitly: ``from blockchainkit.consensus.visualizers import ...``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from blockchainkit.consensus.visualizers.plots import plot_mining_trials, plot_stake_shares
|
|
8
|
+
|
|
9
|
+
__all__ = ["plot_mining_trials", "plot_stake_shares"]
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Plotting helpers for blockchainkit.consensus: mining effort and stake weighting."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import Counter
|
|
6
|
+
from collections.abc import Mapping, Sequence
|
|
7
|
+
|
|
8
|
+
import matplotlib.pyplot as plt
|
|
9
|
+
import numpy as np
|
|
10
|
+
from matplotlib.axes import Axes
|
|
11
|
+
|
|
12
|
+
__all__ = ["plot_mining_trials", "plot_stake_shares"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def plot_mining_trials(
|
|
16
|
+
difficulties: Sequence[int], attempts: Sequence[int], ax: Axes | None = None
|
|
17
|
+
) -> Axes:
|
|
18
|
+
"""Compare measured hash attempts with the expected 2**difficulty.
|
|
19
|
+
|
|
20
|
+
Parameters
|
|
21
|
+
----------
|
|
22
|
+
difficulties : sequence of int
|
|
23
|
+
Difficulty of each mined block.
|
|
24
|
+
attempts : sequence of int
|
|
25
|
+
Hash attempts each search needed (``MiningResult.attempts``).
|
|
26
|
+
ax : matplotlib.axes.Axes, optional
|
|
27
|
+
Axes to draw on; a new figure is created if omitted.
|
|
28
|
+
|
|
29
|
+
Returns
|
|
30
|
+
-------
|
|
31
|
+
matplotlib.axes.Axes
|
|
32
|
+
"""
|
|
33
|
+
if len(difficulties) != len(attempts):
|
|
34
|
+
raise ValueError("difficulties and attempts must have the same length")
|
|
35
|
+
if ax is None:
|
|
36
|
+
_, ax = plt.subplots()
|
|
37
|
+
ax.scatter(difficulties, attempts, color="#2563eb", alpha=0.6, label="measured")
|
|
38
|
+
grid = np.arange(min(difficulties), max(difficulties) + 1)
|
|
39
|
+
ax.plot(grid, 2.0**grid, color="black", label="expected 2^d")
|
|
40
|
+
ax.set_yscale("log", base=2)
|
|
41
|
+
ax.set_xlabel("difficulty d (leading zero bits)")
|
|
42
|
+
ax.set_ylabel("hash attempts")
|
|
43
|
+
ax.set_title("Each extra zero bit doubles the expected work")
|
|
44
|
+
ax.legend()
|
|
45
|
+
return ax
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def plot_stake_shares(
|
|
49
|
+
stakes: Mapping[str, int], proposers: Sequence[str], ax: Axes | None = None
|
|
50
|
+
) -> Axes:
|
|
51
|
+
"""Compare each validator's stake fraction with its observed proposer fraction.
|
|
52
|
+
|
|
53
|
+
Parameters
|
|
54
|
+
----------
|
|
55
|
+
stakes : Mapping
|
|
56
|
+
Validator weights, as given to :class:`~blockchainkit.consensus.systems.pos.StakeSampler`.
|
|
57
|
+
proposers : sequence of str
|
|
58
|
+
Sampled proposers, e.g. from ``StakeSampler.sample``.
|
|
59
|
+
ax : matplotlib.axes.Axes, optional
|
|
60
|
+
Axes to draw on; a new figure is created if omitted.
|
|
61
|
+
|
|
62
|
+
Returns
|
|
63
|
+
-------
|
|
64
|
+
matplotlib.axes.Axes
|
|
65
|
+
"""
|
|
66
|
+
if not proposers:
|
|
67
|
+
raise ValueError("need at least one sampled proposer")
|
|
68
|
+
if ax is None:
|
|
69
|
+
_, ax = plt.subplots()
|
|
70
|
+
names = sorted(stakes)
|
|
71
|
+
total = sum(stakes.values())
|
|
72
|
+
counts = Counter(proposers)
|
|
73
|
+
xs = np.arange(len(names))
|
|
74
|
+
ax.bar(xs - 0.18, [stakes[n] / total for n in names], 0.36, label="stake fraction")
|
|
75
|
+
ax.bar(xs + 0.18, [counts[n] / len(proposers) for n in names], 0.36, label="proposer fraction")
|
|
76
|
+
ax.set_xticks(xs, names)
|
|
77
|
+
ax.set_ylabel("fraction")
|
|
78
|
+
ax.set_title(f"Stake-weighted selection over {len(proposers)} rounds")
|
|
79
|
+
ax.legend()
|
|
80
|
+
return ax
|