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 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))
@@ -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)