sccp 0.1.1__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.
- sccp/__init__.py +12 -0
- sccp/cli.py +303 -0
- sccp/config.py +240 -0
- sccp/crypto/__init__.py +6 -0
- sccp/crypto/address.py +120 -0
- sccp/crypto/keys.py +139 -0
- sccp/crypto/wallet.py +447 -0
- sccp/errors.py +70 -0
- sccp/serialization.py +181 -0
- sccp/version.py +55 -0
- sccp-0.1.1.dist-info/METADATA +457 -0
- sccp-0.1.1.dist-info/RECORD +15 -0
- sccp-0.1.1.dist-info/WHEEL +4 -0
- sccp-0.1.1.dist-info/entry_points.txt +2 -0
- sccp-0.1.1.dist-info/licenses/LICENSE +24 -0
sccp/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""sccp -- a cryptocurrency whose proof of work is winning chess games.
|
|
2
|
+
|
|
3
|
+
The package is import-light on purpose: importing :mod:`sccp` must not start
|
|
4
|
+
engines, open databases or read configuration. Entry points (CLI, node, miner)
|
|
5
|
+
call :func:`sccp.version.verify_chess_version` during startup so an unexpected
|
|
6
|
+
``python-chess`` upgrade fails loudly instead of silently changing PGN parsing
|
|
7
|
+
behaviour (docs/IMPLEMENTATION_PLAN.md section 3).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from sccp.version import __version__
|
|
11
|
+
|
|
12
|
+
__all__ = ["__version__"]
|
sccp/cli.py
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
"""``sccp`` command line interface (docs/IMPLEMENTATION_PLAN.md section 5.13).
|
|
2
|
+
|
|
3
|
+
M0 ships ``--version`` and the wallet basics: ``wallet new``, ``wallet address``,
|
|
4
|
+
``wallet export-mnemonic`` and ``wallet import-mnemonic``. Every command is a thin
|
|
5
|
+
wrapper over a library call so the same behaviour is testable in-process.
|
|
6
|
+
|
|
7
|
+
Global options may be passed before or after the subcommand; an explicitly
|
|
8
|
+
passed ``--network`` always wins over the global default of ``testnet``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json as jsonlib
|
|
14
|
+
import os
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Annotated, NoReturn, TypeVar
|
|
19
|
+
|
|
20
|
+
import typer
|
|
21
|
+
|
|
22
|
+
from sccp import version as version_module
|
|
23
|
+
from sccp.config import NetworkParams, get_network
|
|
24
|
+
from sccp.crypto.wallet import (
|
|
25
|
+
DEFAULT_KDF,
|
|
26
|
+
KdfParams,
|
|
27
|
+
Wallet,
|
|
28
|
+
create_wallet,
|
|
29
|
+
export_mnemonic,
|
|
30
|
+
import_mnemonic,
|
|
31
|
+
read_header,
|
|
32
|
+
read_passphrase_file,
|
|
33
|
+
)
|
|
34
|
+
from sccp.errors import SccpError
|
|
35
|
+
|
|
36
|
+
DEFAULT_NETWORK = "testnet"
|
|
37
|
+
PASSPHRASE_ENV = "SCCP_WALLET_PASSPHRASE"
|
|
38
|
+
|
|
39
|
+
T = TypeVar("T")
|
|
40
|
+
|
|
41
|
+
app = typer.Typer(
|
|
42
|
+
add_completion=False,
|
|
43
|
+
no_args_is_help=True,
|
|
44
|
+
help="A cryptocurrency whose proof of work is winning chess games.",
|
|
45
|
+
)
|
|
46
|
+
wallet_app = typer.Typer(no_args_is_help=True, help="Create, inspect and back up wallets.")
|
|
47
|
+
app.add_typer(wallet_app, name="wallet")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True, slots=True)
|
|
51
|
+
class GlobalOptions:
|
|
52
|
+
"""Values supplied by the global options, before per-command overrides."""
|
|
53
|
+
|
|
54
|
+
network: str | None
|
|
55
|
+
data_dir: Path
|
|
56
|
+
passphrase_file: Path | None
|
|
57
|
+
json_output: bool
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _version_callback(value: bool) -> None:
|
|
61
|
+
if value:
|
|
62
|
+
typer.echo(version_module.version_string())
|
|
63
|
+
raise typer.Exit()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@app.callback()
|
|
67
|
+
def main(
|
|
68
|
+
ctx: typer.Context,
|
|
69
|
+
network: Annotated[
|
|
70
|
+
str | None,
|
|
71
|
+
typer.Option("--network", help="Network id: mainnet, testnet or devnet."),
|
|
72
|
+
] = None,
|
|
73
|
+
data_dir: Annotated[
|
|
74
|
+
Path,
|
|
75
|
+
typer.Option("--data-dir", help="Directory for chain and wallet data."),
|
|
76
|
+
] = Path("./data"),
|
|
77
|
+
passphrase_file: Annotated[
|
|
78
|
+
Path | None,
|
|
79
|
+
typer.Option(
|
|
80
|
+
"--passphrase-file",
|
|
81
|
+
help="File holding the wallet passphrase (mode 0600, first line used).",
|
|
82
|
+
),
|
|
83
|
+
] = None,
|
|
84
|
+
log_level: Annotated[
|
|
85
|
+
str,
|
|
86
|
+
typer.Option("--log-level", help="Logging verbosity (info, debug, ...)."),
|
|
87
|
+
] = "info",
|
|
88
|
+
json_output: Annotated[
|
|
89
|
+
bool,
|
|
90
|
+
typer.Option("--json", help="Print machine-readable JSON."),
|
|
91
|
+
] = False,
|
|
92
|
+
version: Annotated[
|
|
93
|
+
bool,
|
|
94
|
+
typer.Option(
|
|
95
|
+
"--version",
|
|
96
|
+
"-V",
|
|
97
|
+
callback=_version_callback,
|
|
98
|
+
is_eager=True,
|
|
99
|
+
help="Show the package and protocol version, then exit.",
|
|
100
|
+
),
|
|
101
|
+
] = False,
|
|
102
|
+
) -> None:
|
|
103
|
+
"""Shared setup: verify the chess library, validate the network, store options."""
|
|
104
|
+
version_module.verify_chess_version()
|
|
105
|
+
if network is not None:
|
|
106
|
+
try:
|
|
107
|
+
get_network(network)
|
|
108
|
+
except SccpError as exc:
|
|
109
|
+
_fail(str(exc), code=2)
|
|
110
|
+
ctx.obj = GlobalOptions(
|
|
111
|
+
network=network,
|
|
112
|
+
data_dir=data_dir,
|
|
113
|
+
passphrase_file=passphrase_file,
|
|
114
|
+
json_output=json_output,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _options(ctx: typer.Context) -> GlobalOptions:
|
|
119
|
+
options = ctx.obj
|
|
120
|
+
if not isinstance(options, GlobalOptions): # pragma: no cover - callback always sets it
|
|
121
|
+
raise typer.Exit(code=2)
|
|
122
|
+
return options
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _fail(message: str, code: int = 1) -> NoReturn:
|
|
126
|
+
typer.secho(f"error: {message}", err=True)
|
|
127
|
+
raise typer.Exit(code=code)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _run(action: Callable[[], T]) -> T:
|
|
131
|
+
"""Run a library call, turning typed errors into a clean CLI exit."""
|
|
132
|
+
try:
|
|
133
|
+
return action()
|
|
134
|
+
except SccpError as exc:
|
|
135
|
+
_fail(str(exc))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _resolve_network(ctx: typer.Context, network: str | None) -> NetworkParams:
|
|
139
|
+
options = _options(ctx)
|
|
140
|
+
return _run(lambda: get_network(network or options.network or DEFAULT_NETWORK))
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _check_network(ctx: typer.Context, network: str | None, header_network: str) -> None:
|
|
144
|
+
"""Refuse to operate on a keyfile belonging to a different network."""
|
|
145
|
+
explicit = network or _options(ctx).network
|
|
146
|
+
if explicit is None:
|
|
147
|
+
return
|
|
148
|
+
requested = _run(lambda: get_network(explicit))
|
|
149
|
+
if header_network != requested.network_id:
|
|
150
|
+
_fail(
|
|
151
|
+
f"keyfile belongs to network {header_network!r}, "
|
|
152
|
+
f"but --network {requested.network_id!r} was requested"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _passphrase(ctx: typer.Context, *, confirm: bool) -> str:
|
|
157
|
+
options = _options(ctx)
|
|
158
|
+
passphrase_file = options.passphrase_file
|
|
159
|
+
if passphrase_file is not None:
|
|
160
|
+
return _run(lambda: read_passphrase_file(passphrase_file))
|
|
161
|
+
from_env = os.environ.get(PASSPHRASE_ENV)
|
|
162
|
+
if from_env:
|
|
163
|
+
return from_env
|
|
164
|
+
return str(typer.prompt("Passphrase", hide_input=True, confirmation_prompt=confirm))
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _echo_json(payload: object) -> None:
|
|
168
|
+
typer.echo(jsonlib.dumps(payload, sort_keys=True))
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _emit_wallet(ctx: typer.Context, wallet: Wallet) -> None:
|
|
172
|
+
if _options(ctx).json_output:
|
|
173
|
+
_echo_json(
|
|
174
|
+
{
|
|
175
|
+
"address": wallet.address,
|
|
176
|
+
"network": wallet.network,
|
|
177
|
+
"public_key": wallet.public_key_hex,
|
|
178
|
+
"keyfile": str(wallet.path) if wallet.path is not None else None,
|
|
179
|
+
}
|
|
180
|
+
)
|
|
181
|
+
return
|
|
182
|
+
typer.echo(wallet.address)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@wallet_app.command("new")
|
|
186
|
+
def wallet_new(
|
|
187
|
+
ctx: typer.Context,
|
|
188
|
+
out: Annotated[Path, typer.Option("--out", help="Path of the keyfile to write.")],
|
|
189
|
+
network: Annotated[
|
|
190
|
+
str | None,
|
|
191
|
+
typer.Option("--network", help="Network the address is derived for."),
|
|
192
|
+
] = None,
|
|
193
|
+
force: Annotated[
|
|
194
|
+
bool,
|
|
195
|
+
typer.Option("--force", help="Overwrite an existing keyfile."),
|
|
196
|
+
] = False,
|
|
197
|
+
created_at: Annotated[
|
|
198
|
+
int | None,
|
|
199
|
+
typer.Option("--created-at", hidden=True, help="Fix created_at (tests)."),
|
|
200
|
+
] = None,
|
|
201
|
+
kdf_n: Annotated[
|
|
202
|
+
int,
|
|
203
|
+
typer.Option("--kdf-n", hidden=True, help="scrypt N (tests only; default 2**15)."),
|
|
204
|
+
] = DEFAULT_KDF.n,
|
|
205
|
+
) -> None:
|
|
206
|
+
"""Create an encrypted keyfile and print its address."""
|
|
207
|
+
params = _resolve_network(ctx, network)
|
|
208
|
+
passphrase = _passphrase(ctx, confirm=True)
|
|
209
|
+
wallet = _run(
|
|
210
|
+
lambda: create_wallet(
|
|
211
|
+
out,
|
|
212
|
+
params,
|
|
213
|
+
passphrase,
|
|
214
|
+
kdf=KdfParams(n=kdf_n),
|
|
215
|
+
created_at=created_at,
|
|
216
|
+
force=force,
|
|
217
|
+
)
|
|
218
|
+
)
|
|
219
|
+
_emit_wallet(ctx, wallet)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@wallet_app.command("address")
|
|
223
|
+
def wallet_address(
|
|
224
|
+
ctx: typer.Context,
|
|
225
|
+
key: Annotated[Path, typer.Option("--key", help="Keyfile to read.")],
|
|
226
|
+
network: Annotated[
|
|
227
|
+
str | None,
|
|
228
|
+
typer.Option("--network", help="Require the keyfile to belong to this network."),
|
|
229
|
+
] = None,
|
|
230
|
+
) -> None:
|
|
231
|
+
"""Print the address stored in a keyfile; no passphrase is needed."""
|
|
232
|
+
header = _run(lambda: read_header(key))
|
|
233
|
+
_check_network(ctx, network, header.network)
|
|
234
|
+
if _options(ctx).json_output:
|
|
235
|
+
_echo_json(
|
|
236
|
+
{
|
|
237
|
+
"address": header.address,
|
|
238
|
+
"network": header.network,
|
|
239
|
+
"public_key": header.public_key,
|
|
240
|
+
"keyfile": str(key),
|
|
241
|
+
}
|
|
242
|
+
)
|
|
243
|
+
return
|
|
244
|
+
typer.echo(header.address)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
@wallet_app.command("export-mnemonic")
|
|
248
|
+
def wallet_export_mnemonic(
|
|
249
|
+
ctx: typer.Context,
|
|
250
|
+
key: Annotated[Path, typer.Option("--key", help="Keyfile to decrypt.")],
|
|
251
|
+
network: Annotated[
|
|
252
|
+
str | None,
|
|
253
|
+
typer.Option("--network", help="Require the keyfile to belong to this network."),
|
|
254
|
+
] = None,
|
|
255
|
+
) -> None:
|
|
256
|
+
"""Print the 24-word BIP-39 backup phrase for a keyfile."""
|
|
257
|
+
header = _run(lambda: read_header(key))
|
|
258
|
+
_check_network(ctx, network, header.network)
|
|
259
|
+
passphrase = _passphrase(ctx, confirm=False)
|
|
260
|
+
phrase = _run(lambda: export_mnemonic(key, passphrase))
|
|
261
|
+
if _options(ctx).json_output:
|
|
262
|
+
_echo_json({"mnemonic": phrase, "address": header.address, "network": header.network})
|
|
263
|
+
return
|
|
264
|
+
typer.echo(phrase)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@wallet_app.command("import-mnemonic")
|
|
268
|
+
def wallet_import_mnemonic(
|
|
269
|
+
ctx: typer.Context,
|
|
270
|
+
out: Annotated[Path, typer.Option("--out", help="Path of the keyfile to write.")],
|
|
271
|
+
network: Annotated[
|
|
272
|
+
str | None,
|
|
273
|
+
typer.Option("--network", help="Network the address is derived for."),
|
|
274
|
+
] = None,
|
|
275
|
+
force: Annotated[
|
|
276
|
+
bool,
|
|
277
|
+
typer.Option("--force", help="Overwrite an existing keyfile."),
|
|
278
|
+
] = False,
|
|
279
|
+
created_at: Annotated[
|
|
280
|
+
int | None,
|
|
281
|
+
typer.Option("--created-at", hidden=True, help="Fix created_at (tests)."),
|
|
282
|
+
] = None,
|
|
283
|
+
kdf_n: Annotated[
|
|
284
|
+
int,
|
|
285
|
+
typer.Option("--kdf-n", hidden=True, help="scrypt N (tests only; default 2**15)."),
|
|
286
|
+
] = DEFAULT_KDF.n,
|
|
287
|
+
) -> None:
|
|
288
|
+
"""Recreate a keyfile from a 24-word backup phrase."""
|
|
289
|
+
params = _resolve_network(ctx, network)
|
|
290
|
+
phrase = typer.prompt("Mnemonic (24 words)", hide_input=True)
|
|
291
|
+
passphrase = _passphrase(ctx, confirm=True)
|
|
292
|
+
wallet = _run(
|
|
293
|
+
lambda: import_mnemonic(
|
|
294
|
+
out,
|
|
295
|
+
phrase,
|
|
296
|
+
params,
|
|
297
|
+
passphrase,
|
|
298
|
+
kdf=KdfParams(n=kdf_n),
|
|
299
|
+
created_at=created_at,
|
|
300
|
+
force=force,
|
|
301
|
+
)
|
|
302
|
+
)
|
|
303
|
+
_emit_wallet(ctx, wallet)
|
sccp/config.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Immutable network parameters (docs/IMPLEMENTATION_PLAN.md section 5.1).
|
|
2
|
+
|
|
3
|
+
Amounts are integer base units: ``1 coin = 100_000_000 units``. No floating
|
|
4
|
+
point value is ever accepted by this module. ``NetworkParams`` validates its own
|
|
5
|
+
invariants at construction, so an impossible network definition fails at import
|
|
6
|
+
time rather than at block validation time.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from enum import StrEnum
|
|
12
|
+
from typing import Final, Self
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
15
|
+
|
|
16
|
+
from sccp.errors import ConfigError, NetworkError
|
|
17
|
+
|
|
18
|
+
UNITS_PER_COIN: Final = 100_000_000
|
|
19
|
+
"""Base units per coin. All consensus amounts are integers of this unit."""
|
|
20
|
+
|
|
21
|
+
MAX_PLIES_LIMIT: Final = 1000
|
|
22
|
+
"""Hard upper bound for ``max_plies``; longer games are never consensus-valid."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def coins_to_units(coins: int) -> int:
|
|
26
|
+
"""Convert whole coins to base units."""
|
|
27
|
+
if not isinstance(coins, int) or isinstance(coins, bool):
|
|
28
|
+
raise ConfigError("coin amounts must be integers")
|
|
29
|
+
return coins * UNITS_PER_COIN
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class QualityMode(StrEnum):
|
|
33
|
+
"""Whether the quality policy is enforced for a network."""
|
|
34
|
+
|
|
35
|
+
OFF = "off"
|
|
36
|
+
SAMPLED = "sampled"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class NetworkParams(BaseModel):
|
|
40
|
+
"""One network's consensus constants. Frozen: instances are hashable and safe."""
|
|
41
|
+
|
|
42
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
43
|
+
|
|
44
|
+
# Identity
|
|
45
|
+
network_id: str = Field(min_length=1)
|
|
46
|
+
address_hrp: str = Field(min_length=1)
|
|
47
|
+
genesis_timestamp: int | None = None
|
|
48
|
+
|
|
49
|
+
# Epochs and matchmaking
|
|
50
|
+
epoch_length: int = Field(ge=1)
|
|
51
|
+
rounds_per_epoch: int = Field(ge=1)
|
|
52
|
+
availability_lead: int = Field(ge=2)
|
|
53
|
+
wins_required: int = Field(ge=1)
|
|
54
|
+
k_min: int = Field(ge=1)
|
|
55
|
+
k_max: int = Field(ge=1)
|
|
56
|
+
claim_window: int = Field(ge=1)
|
|
57
|
+
opponent_cooldown: int = Field(ge=0)
|
|
58
|
+
|
|
59
|
+
# Header seal and retargeting
|
|
60
|
+
seal_bits: int = Field(ge=0, le=32)
|
|
61
|
+
target_block_seconds: int = Field(ge=1)
|
|
62
|
+
retarget_interval: int = Field(ge=1)
|
|
63
|
+
retarget_enabled: bool = True
|
|
64
|
+
|
|
65
|
+
# Economics
|
|
66
|
+
subsidy: int = Field(gt=0)
|
|
67
|
+
halving_interval: int | None = Field(default=None, ge=1)
|
|
68
|
+
max_supply: int | None = Field(default=None, ge=1)
|
|
69
|
+
coinbase_maturity: int = Field(ge=0)
|
|
70
|
+
min_bond: int = Field(ge=0)
|
|
71
|
+
registration_epochs: int = Field(ge=1)
|
|
72
|
+
unbond_delay: int = Field(ge=0)
|
|
73
|
+
min_fee: int = Field(gt=0)
|
|
74
|
+
challenge_window: int = Field(ge=0)
|
|
75
|
+
|
|
76
|
+
# Chess rules
|
|
77
|
+
min_plies: int = Field(ge=1)
|
|
78
|
+
max_plies: int = Field(ge=1, le=MAX_PLIES_LIMIT)
|
|
79
|
+
nodes_per_move: int = Field(gt=0)
|
|
80
|
+
book_version: str = Field(min_length=1)
|
|
81
|
+
|
|
82
|
+
# Quality policy
|
|
83
|
+
quality_mode: QualityMode
|
|
84
|
+
quality_sample_rate: int = Field(ge=0, le=1000)
|
|
85
|
+
|
|
86
|
+
# Beacon
|
|
87
|
+
beacon_grace_blocks: int = Field(ge=0)
|
|
88
|
+
drand_chain: str | None = None
|
|
89
|
+
drand_period_seconds: int | None = Field(default=None, ge=1)
|
|
90
|
+
drand_round_offset: int | None = None
|
|
91
|
+
|
|
92
|
+
# Size caps
|
|
93
|
+
max_block_bytes: int = Field(gt=0)
|
|
94
|
+
max_pgn_bytes: int = Field(gt=0)
|
|
95
|
+
max_tx_bytes: int = Field(gt=0)
|
|
96
|
+
|
|
97
|
+
@property
|
|
98
|
+
def max_money(self) -> int | None:
|
|
99
|
+
"""Supply cap in base units, or ``None`` when the network is uncapped."""
|
|
100
|
+
if self.max_supply is None:
|
|
101
|
+
return None
|
|
102
|
+
return self.max_supply * UNITS_PER_COIN
|
|
103
|
+
|
|
104
|
+
@model_validator(mode="after")
|
|
105
|
+
def _check_invariants(self) -> Self:
|
|
106
|
+
if self.retarget_interval % self.epoch_length != 0:
|
|
107
|
+
raise ValueError("retarget_interval must be a multiple of epoch_length")
|
|
108
|
+
if not self.k_min <= self.wins_required <= self.k_max:
|
|
109
|
+
raise ValueError("wins_required must satisfy k_min <= k <= k_max")
|
|
110
|
+
if self.min_plies > self.max_plies:
|
|
111
|
+
raise ValueError("min_plies must not exceed max_plies")
|
|
112
|
+
if self.max_block_bytes <= max(self.max_pgn_bytes, self.max_tx_bytes):
|
|
113
|
+
raise ValueError("max_block_bytes must exceed the PGN and tx caps")
|
|
114
|
+
if (self.max_supply is None) != (self.halving_interval is None):
|
|
115
|
+
raise ValueError("max_supply and halving_interval are set together or not at all")
|
|
116
|
+
drand_fields = (self.drand_period_seconds, self.drand_round_offset)
|
|
117
|
+
if self.drand_chain is None and any(field is not None for field in drand_fields):
|
|
118
|
+
raise ValueError("drand timing constants require drand_chain")
|
|
119
|
+
if self.drand_chain is not None and any(field is None for field in drand_fields):
|
|
120
|
+
raise ValueError("drand_chain requires drand_period_seconds and drand_round_offset")
|
|
121
|
+
if self.quality_mode is QualityMode.SAMPLED and self.quality_sample_rate == 0:
|
|
122
|
+
raise ValueError("quality_mode 'sampled' requires a non-zero quality_sample_rate")
|
|
123
|
+
|
|
124
|
+
max_money = self.max_money
|
|
125
|
+
if max_money is not None:
|
|
126
|
+
for name, amount in (
|
|
127
|
+
("subsidy", self.subsidy),
|
|
128
|
+
("min_bond", self.min_bond),
|
|
129
|
+
("min_fee", self.min_fee),
|
|
130
|
+
):
|
|
131
|
+
if amount > max_money:
|
|
132
|
+
raise ValueError(f"{name} exceeds MAX_MONEY ({max_money})")
|
|
133
|
+
return self
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
TESTNET: Final = NetworkParams(
|
|
137
|
+
network_id="testnet",
|
|
138
|
+
address_hrp="sccpt",
|
|
139
|
+
epoch_length=5,
|
|
140
|
+
rounds_per_epoch=3,
|
|
141
|
+
availability_lead=2,
|
|
142
|
+
wins_required=1,
|
|
143
|
+
k_min=1,
|
|
144
|
+
k_max=5,
|
|
145
|
+
claim_window=64,
|
|
146
|
+
opponent_cooldown=1,
|
|
147
|
+
seal_bits=0,
|
|
148
|
+
target_block_seconds=60,
|
|
149
|
+
retarget_interval=10,
|
|
150
|
+
subsidy=coins_to_units(50),
|
|
151
|
+
coinbase_maturity=20,
|
|
152
|
+
min_bond=coins_to_units(10),
|
|
153
|
+
registration_epochs=10,
|
|
154
|
+
unbond_delay=50,
|
|
155
|
+
min_fee=1_000,
|
|
156
|
+
challenge_window=30,
|
|
157
|
+
min_plies=20,
|
|
158
|
+
max_plies=300,
|
|
159
|
+
nodes_per_move=50_000,
|
|
160
|
+
book_version="dev-1",
|
|
161
|
+
quality_mode=QualityMode.OFF,
|
|
162
|
+
quality_sample_rate=0,
|
|
163
|
+
beacon_grace_blocks=2,
|
|
164
|
+
max_block_bytes=1_048_576,
|
|
165
|
+
max_pgn_bytes=262_144,
|
|
166
|
+
max_tx_bytes=65_536,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
MAINNET: Final = NetworkParams(
|
|
170
|
+
network_id="mainnet",
|
|
171
|
+
address_hrp="sccp",
|
|
172
|
+
epoch_length=20,
|
|
173
|
+
rounds_per_epoch=5,
|
|
174
|
+
availability_lead=2,
|
|
175
|
+
wins_required=3,
|
|
176
|
+
k_min=1,
|
|
177
|
+
k_max=10,
|
|
178
|
+
claim_window=144,
|
|
179
|
+
opponent_cooldown=4,
|
|
180
|
+
seal_bits=16,
|
|
181
|
+
target_block_seconds=600,
|
|
182
|
+
retarget_interval=40,
|
|
183
|
+
subsidy=coins_to_units(50),
|
|
184
|
+
halving_interval=210_000,
|
|
185
|
+
max_supply=21_000_000,
|
|
186
|
+
coinbase_maturity=100,
|
|
187
|
+
min_bond=coins_to_units(500),
|
|
188
|
+
registration_epochs=30,
|
|
189
|
+
unbond_delay=1_000,
|
|
190
|
+
min_fee=1_000,
|
|
191
|
+
challenge_window=100,
|
|
192
|
+
min_plies=30,
|
|
193
|
+
max_plies=400,
|
|
194
|
+
nodes_per_move=250_000,
|
|
195
|
+
book_version="v1",
|
|
196
|
+
quality_mode=QualityMode.SAMPLED,
|
|
197
|
+
quality_sample_rate=50,
|
|
198
|
+
beacon_grace_blocks=6,
|
|
199
|
+
drand_chain="quicknet",
|
|
200
|
+
drand_period_seconds=3,
|
|
201
|
+
drand_round_offset=1,
|
|
202
|
+
max_block_bytes=1_048_576,
|
|
203
|
+
max_pgn_bytes=262_144,
|
|
204
|
+
max_tx_bytes=65_536,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
DEVNET: Final = NetworkParams.model_validate(
|
|
208
|
+
{
|
|
209
|
+
**TESTNET.model_dump(),
|
|
210
|
+
"network_id": "devnet",
|
|
211
|
+
# Fully deterministic: one-block epochs, no retargeting, fixed genesis time.
|
|
212
|
+
"epoch_length": 1,
|
|
213
|
+
"target_block_seconds": 1,
|
|
214
|
+
"retarget_enabled": False,
|
|
215
|
+
"wins_required": 1,
|
|
216
|
+
"min_bond": 0,
|
|
217
|
+
"genesis_timestamp": 1_700_000_000,
|
|
218
|
+
}
|
|
219
|
+
)
|
|
220
|
+
"""In-process network for tests and CI: mock engines, zero seal difficulty."""
|
|
221
|
+
|
|
222
|
+
NETWORKS: Final[dict[str, NetworkParams]] = {
|
|
223
|
+
params.network_id: params for params in (TESTNET, MAINNET, DEVNET)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def get_network(network: str | NetworkParams) -> NetworkParams:
|
|
228
|
+
"""Resolve a network id (or pass a :class:`NetworkParams` through)."""
|
|
229
|
+
if isinstance(network, NetworkParams):
|
|
230
|
+
return network
|
|
231
|
+
try:
|
|
232
|
+
return NETWORKS[network]
|
|
233
|
+
except KeyError:
|
|
234
|
+
known = ", ".join(sorted(NETWORKS))
|
|
235
|
+
raise NetworkError(f"unknown network {network!r}; known networks: {known}") from None
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def network_ids() -> tuple[str, ...]:
|
|
239
|
+
"""All known network ids, sorted."""
|
|
240
|
+
return tuple(sorted(NETWORKS))
|
sccp/crypto/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Key, address and wallet primitives.
|
|
2
|
+
|
|
3
|
+
Import the submodules directly (``from sccp.crypto.keys import sign``); this
|
|
4
|
+
package deliberately re-exports nothing so that a light-weight consumer such as
|
|
5
|
+
the address encoder never pays for the wallet's key-derivation code.
|
|
6
|
+
"""
|
sccp/crypto/address.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Bech32 addresses: ``sccp1...`` on mainnet, ``sccpt1...`` on testnet/devnet.
|
|
2
|
+
|
|
3
|
+
An address is a 21-byte program -- one version byte (``0`` = Ed25519) plus the
|
|
4
|
+
first 20 bytes of ``sha256("sccp/v1/address" + 0x00 + canonical_json({"network",
|
|
5
|
+
"public_key"}))`` -- encoded with BIP-173 bech32. The segwit-style helpers in the
|
|
6
|
+
``bech32`` package are not usable here: they hard-code witness program lengths
|
|
7
|
+
and reject the 21-byte program this protocol needs, so we use the primitives
|
|
8
|
+
``bech32_encode``/``bech32_decode``/``convertbits`` directly
|
|
9
|
+
(docs/IMPLEMENTATION_PLAN.md section 5.3).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Final
|
|
16
|
+
|
|
17
|
+
import bech32
|
|
18
|
+
|
|
19
|
+
from sccp.config import NetworkParams, get_network
|
|
20
|
+
from sccp.crypto.keys import PUBLIC_KEY_SIZE
|
|
21
|
+
from sccp.errors import AddressError, KeyEncodingError
|
|
22
|
+
from sccp.serialization import Domain, domain_hash
|
|
23
|
+
|
|
24
|
+
ADDRESS_VERSION: Final = 0
|
|
25
|
+
"""Program version byte for Ed25519 keys."""
|
|
26
|
+
|
|
27
|
+
HASH_SIZE: Final = 20
|
|
28
|
+
"""Address hash length in bytes."""
|
|
29
|
+
|
|
30
|
+
PROGRAM_SIZE: Final = 1 + HASH_SIZE
|
|
31
|
+
"""Version byte plus hash."""
|
|
32
|
+
|
|
33
|
+
MAX_ADDRESS_LENGTH: Final = 90
|
|
34
|
+
"""BIP-173 limit, in characters."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class DecodedAddress:
|
|
39
|
+
"""A syntactically valid bech32 address, independent of network choice."""
|
|
40
|
+
|
|
41
|
+
hrp: str
|
|
42
|
+
version: int
|
|
43
|
+
payload: bytes
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def program(self) -> bytes:
|
|
47
|
+
"""The 21-byte ``version || hash`` program."""
|
|
48
|
+
return bytes([self.version]) + self.payload
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def hash_hex(self) -> str:
|
|
52
|
+
return self.payload.hex()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def address_hash(network_id: str, public_key: bytes | bytearray) -> bytes:
|
|
56
|
+
"""The 20-byte address hash for ``public_key`` on ``network_id``."""
|
|
57
|
+
raw = bytes(public_key)
|
|
58
|
+
if len(raw) != PUBLIC_KEY_SIZE:
|
|
59
|
+
raise KeyEncodingError(
|
|
60
|
+
f"Ed25519 public key must be {PUBLIC_KEY_SIZE} bytes, got {len(raw)}"
|
|
61
|
+
)
|
|
62
|
+
digest = domain_hash(Domain.ADDRESS, {"network": network_id, "public_key": raw.hex()})
|
|
63
|
+
return digest[:HASH_SIZE]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def address_program(network_id: str, public_key: bytes | bytearray) -> bytes:
|
|
67
|
+
"""The 21-byte program committed to by the bech32 string."""
|
|
68
|
+
return bytes([ADDRESS_VERSION]) + address_hash(network_id, public_key)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def encode_address(public_key: bytes | bytearray, network: str | NetworkParams) -> str:
|
|
72
|
+
"""Encode ``public_key`` as a lowercase bech32 address on ``network``."""
|
|
73
|
+
params = get_network(network)
|
|
74
|
+
program = address_program(params.network_id, public_key)
|
|
75
|
+
words = bech32.convertbits(program, 8, 5, True)
|
|
76
|
+
if words is None: # pragma: no cover - impossible for 21 bytes
|
|
77
|
+
raise AddressError("cannot convert address program to 5-bit words")
|
|
78
|
+
return bech32.bech32_encode(params.address_hrp, words)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _decode_words(address: str) -> tuple[str, list[int]]:
|
|
82
|
+
if not isinstance(address, str):
|
|
83
|
+
raise AddressError(f"address must be a string, got {type(address).__name__}")
|
|
84
|
+
if not address:
|
|
85
|
+
raise AddressError("address is empty")
|
|
86
|
+
if len(address) > MAX_ADDRESS_LENGTH:
|
|
87
|
+
raise AddressError(f"address exceeds {MAX_ADDRESS_LENGTH} characters")
|
|
88
|
+
if address.lower() != address and address.upper() != address:
|
|
89
|
+
raise AddressError("address mixes upper and lower case")
|
|
90
|
+
hrp, words = bech32.bech32_decode(address)
|
|
91
|
+
if hrp is None or words is None:
|
|
92
|
+
raise AddressError(f"invalid bech32 address: {address!r}")
|
|
93
|
+
return hrp, words
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def decode_address(address: str) -> DecodedAddress:
|
|
97
|
+
"""Parse any well-formed sccp address, regardless of network."""
|
|
98
|
+
hrp, words = _decode_words(address)
|
|
99
|
+
program = bech32.convertbits(words, 5, 8, False)
|
|
100
|
+
if program is None or len(program) != PROGRAM_SIZE:
|
|
101
|
+
raise AddressError(f"address program must be {PROGRAM_SIZE} bytes")
|
|
102
|
+
version = program[0]
|
|
103
|
+
if version != ADDRESS_VERSION:
|
|
104
|
+
raise AddressError(f"unsupported address version {version}")
|
|
105
|
+
return DecodedAddress(hrp=hrp, version=version, payload=bytes(program[1:]))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def validate_address(address: str, network: str | NetworkParams) -> bool:
|
|
109
|
+
"""Return whether ``address`` parses and belongs to ``network``."""
|
|
110
|
+
params = get_network(network)
|
|
111
|
+
try:
|
|
112
|
+
decoded = decode_address(address)
|
|
113
|
+
except AddressError:
|
|
114
|
+
return False
|
|
115
|
+
return decoded.hrp == params.address_hrp
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def address_for_public_key(public_key: bytes | bytearray, network: str | NetworkParams) -> str:
|
|
119
|
+
"""Alias for :func:`encode_address`, spelled for readability at call sites."""
|
|
120
|
+
return encode_address(public_key, network)
|