orangecheck 2.0.0__tar.gz → 2.2.0__tar.gz
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.
- {orangecheck-2.0.0 → orangecheck-2.2.0}/CHANGELOG.md +33 -0
- {orangecheck-2.0.0 → orangecheck-2.2.0}/PKG-INFO +1 -1
- {orangecheck-2.0.0 → orangecheck-2.2.0}/orangecheck/__init__.py +24 -1
- orangecheck-2.2.0/orangecheck/bond.py +226 -0
- {orangecheck-2.0.0 → orangecheck-2.2.0}/pyproject.toml +1 -1
- orangecheck-2.2.0/tests/test_bond_address.py +34 -0
- {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/test_conformance.py +62 -1
- {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/vectors/index.json +25 -0
- orangecheck-2.2.0/tests/vectors/tv24.json +17 -0
- orangecheck-2.2.0/tests/vectors/tv25.json +27 -0
- orangecheck-2.2.0/tests/vectors/tv26.json +36 -0
- orangecheck-2.2.0/tests/vectors/tv27.json +19 -0
- orangecheck-2.2.0/tests/vectors/tv28.json +53 -0
- {orangecheck-2.0.0 → orangecheck-2.2.0}/.gitignore +0 -0
- {orangecheck-2.0.0 → orangecheck-2.2.0}/README.md +0 -0
- {orangecheck-2.0.0 → orangecheck-2.2.0}/orangecheck/__main__.py +0 -0
- {orangecheck-2.0.0 → orangecheck-2.2.0}/orangecheck/canonical.py +0 -0
- {orangecheck-2.0.0 → orangecheck-2.2.0}/orangecheck/client.py +0 -0
- {orangecheck-2.0.0 → orangecheck-2.2.0}/orangecheck/errors.py +0 -0
- {orangecheck-2.0.0 → orangecheck-2.2.0}/orangecheck/pledge/__init__.py +0 -0
- {orangecheck-2.0.0 → orangecheck-2.2.0}/orangecheck/pledge/bond.py +0 -0
- {orangecheck-2.0.0 → orangecheck-2.2.0}/orangecheck/pledge/canonical.py +0 -0
- {orangecheck-2.0.0 → orangecheck-2.2.0}/orangecheck/pledge/delegation.py +0 -0
- {orangecheck-2.0.0 → orangecheck-2.2.0}/orangecheck/pledge/envelopes.py +0 -0
- {orangecheck-2.0.0 → orangecheck-2.2.0}/orangecheck/pledge/resolution.py +0 -0
- {orangecheck-2.0.0 → orangecheck-2.2.0}/orangecheck/pledge/state.py +0 -0
- {orangecheck-2.0.0 → orangecheck-2.2.0}/orangecheck/py.typed +0 -0
- {orangecheck-2.0.0 → orangecheck-2.2.0}/orangecheck/top_level.py +0 -0
- {orangecheck-2.0.0 → orangecheck-2.2.0}/orangecheck/types.py +0 -0
- {orangecheck-2.0.0 → orangecheck-2.2.0}/orangecheck/verify_sig.py +0 -0
- {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/pledge/__init__.py +0 -0
- {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/pledge/test_authority.py +0 -0
- {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/pledge/test_delegation.py +0 -0
- {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/pledge/test_vectors.py +0 -0
- {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/pledge/test_wrap.py +0 -0
- {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/test_client.py +0 -0
- {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/test_client_failure_modes.py +0 -0
- {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/vectors/tv01.json +0 -0
- {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/vectors/tv02.json +0 -0
- {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/vectors/tv03.json +0 -0
- {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/vectors/tv04.json +0 -0
- {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/vectors/tv05.json +0 -0
- {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/vectors/tv06.json +0 -0
- {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/vectors/tv07.json +0 -0
- {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/vectors/tv08.json +0 -0
- {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/vectors/tv09.json +0 -0
- {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/vectors/tv10.json +0 -0
- {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/vectors/tv11.json +0 -0
- {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/vectors/tv12.json +0 -0
- {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/vectors/tv13.json +0 -0
- {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/vectors/tv14.json +0 -0
- {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/vectors/tv15.json +0 -0
- {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/vectors/tv16.json +0 -0
- {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/vectors/tv17.json +0 -0
- {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/vectors/tv18.json +0 -0
- {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/vectors/tv19.json +0 -0
- {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/vectors/tv20.json +0 -0
- {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/vectors/tv21.json +0 -0
- {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/vectors/tv22.json +0 -0
- {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/vectors/tv23.json +0 -0
|
@@ -4,6 +4,39 @@ All notable changes to the **`orangecheck`** Python package. Follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
|
|
5
5
|
[Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## [2.2.0] — 2026-10-16
|
|
8
|
+
|
|
9
|
+
Matches `@orangecheck/sdk` 1.13.0 and the spec's regenerated `tv24`–`tv27`.
|
|
10
|
+
|
|
11
|
+
### Changed (security)
|
|
12
|
+
|
|
13
|
+
- `bond_nullifier(key, audience, outpoint)` takes the registry's secret key
|
|
14
|
+
(at least 32 bytes) and returns HMAC-SHA256. 2.1.0's unkeyed hash could be
|
|
15
|
+
inverted by hashing every unspent output, which found the coin.
|
|
16
|
+
- The signed acknowledgement reads "I back this subject with this coin. It
|
|
17
|
+
stays in my wallet."
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- `verify_bond_signature` refuses a signing window longer than an hour
|
|
22
|
+
(`bond_window_too_long`; `max_window_seconds`, `BOND_MAX_WINDOW_SECONDS`).
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- A bond's address is checked the way `@orangecheck/bip322` checks it,
|
|
27
|
+
checksum included, so a mistyped address is `bond_malformed` here as in
|
|
28
|
+
TypeScript rather than `sig_invalid`.
|
|
29
|
+
|
|
30
|
+
## [2.1.0] — 2026-10-16
|
|
31
|
+
|
|
32
|
+
### Added — bonds (oc-attest-protocol `BOND.md`)
|
|
33
|
+
|
|
34
|
+
- `BondFields`, `build_bond_message`, `parse_bond_message`, `bond_id`,
|
|
35
|
+
`bond_nullifier`, `bond_floors`, `verify_bond_signature`: the same message,
|
|
36
|
+
id, nullifier and floors as `@orangecheck/sdk` 1.12, held to the spec's
|
|
37
|
+
vectors `tv24`–`tv28`. Bond signatures verify through the `[verify]` extra,
|
|
38
|
+
an implementation independent of the TypeScript one.
|
|
39
|
+
|
|
7
40
|
## [2.0.0] — 2026-10-08
|
|
8
41
|
|
|
9
42
|
`orangecheck.pledge` now matches `@orangecheck/pledge-core` 3.1 on who may
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: orangecheck
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.2.0
|
|
4
4
|
Summary: Python SDK for OrangeCheck — proof of Bitcoin stake + the OC Pledge primitive (commitments) for the open web.
|
|
5
5
|
Project-URL: Homepage, https://ochk.io
|
|
6
6
|
Project-URL: Documentation, https://ochk.io/docs
|
|
@@ -41,6 +41,18 @@ try:
|
|
|
41
41
|
from .verify_sig import verify_bip322_signature
|
|
42
42
|
except ImportError: # pragma: no cover - optional-dep path
|
|
43
43
|
verify_bip322_signature = None # type: ignore[assignment]
|
|
44
|
+
from .bond import (
|
|
45
|
+
BOND_DAYS_FLOORS,
|
|
46
|
+
BOND_MAX_WINDOW_SECONDS,
|
|
47
|
+
BOND_SATS_FLOORS,
|
|
48
|
+
BondFields,
|
|
49
|
+
bond_floors,
|
|
50
|
+
bond_id,
|
|
51
|
+
bond_nullifier,
|
|
52
|
+
build_bond_message,
|
|
53
|
+
parse_bond_message,
|
|
54
|
+
verify_bond_signature,
|
|
55
|
+
)
|
|
44
56
|
from .client import AsyncClient, Client
|
|
45
57
|
from .errors import OrangeCheckError, RateLimitError, VerificationError
|
|
46
58
|
from .top_level import challenge_issue, challenge_verify, check, discover, verify
|
|
@@ -76,6 +88,17 @@ __all__ = [
|
|
|
76
88
|
# the name is re-exported as None when the extra isn't installed so callers
|
|
77
89
|
# can check `if verify_bip322_signature is not None`).
|
|
78
90
|
"verify_bip322_signature",
|
|
91
|
+
# Bonds (oc-attest-protocol BOND.md): one coin backing one subject
|
|
92
|
+
"BondFields",
|
|
93
|
+
"build_bond_message",
|
|
94
|
+
"parse_bond_message",
|
|
95
|
+
"bond_id",
|
|
96
|
+
"bond_nullifier",
|
|
97
|
+
"bond_floors",
|
|
98
|
+
"verify_bond_signature",
|
|
99
|
+
"BOND_SATS_FLOORS",
|
|
100
|
+
"BOND_DAYS_FLOORS",
|
|
101
|
+
"BOND_MAX_WINDOW_SECONDS",
|
|
79
102
|
# Types
|
|
80
103
|
"CheckResult",
|
|
81
104
|
"VerifyOutcome",
|
|
@@ -90,7 +113,7 @@ __all__ = [
|
|
|
90
113
|
"VerificationError",
|
|
91
114
|
]
|
|
92
115
|
|
|
93
|
-
__version__ = "2.
|
|
116
|
+
__version__ = "2.2.0"
|
|
94
117
|
|
|
95
118
|
# OC Pledge submodule — sibling primitive for forward-looking commitments
|
|
96
119
|
# bound to a Bitcoin address. Imported here so consumers can write:
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Bonds (oc-attest-protocol BOND.md): one unspent coin backing one subject.
|
|
3
|
+
The coin stays in its holder's wallet; spending it ends the bond. An audience
|
|
4
|
+
sees floors and a keyed per-audience nullifier, never the coin.
|
|
5
|
+
|
|
6
|
+
The message, id, nullifier and floors are pure and need nothing installed.
|
|
7
|
+
``verify_bond_signature`` needs the ``[verify]`` extra for BIP-322. Checking
|
|
8
|
+
the coin itself (unspent, confirmed, at the address) is chain state: read it
|
|
9
|
+
from a node or an Esplora API you trust and compare it the way BOND.md §3.5
|
|
10
|
+
says.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import hmac
|
|
17
|
+
import re
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
from typing import Optional
|
|
21
|
+
|
|
22
|
+
HEADER = "orangecheck-bond"
|
|
23
|
+
ACK = "I back this subject with this coin. It stays in my wallet."
|
|
24
|
+
FIELDS = ("address", "outpoint", "subject", "nonce", "issued_at", "expires_at")
|
|
25
|
+
|
|
26
|
+
#: Value floors a bond claim reports, in sats (BOND.md §5).
|
|
27
|
+
BOND_SATS_FLOORS = (1_000, 10_000, 100_000, 1_000_000, 10_000_000)
|
|
28
|
+
#: Age floors a bond claim reports, in days of 144 blocks (BOND.md §5).
|
|
29
|
+
BOND_DAYS_FLOORS = (7, 30, 90, 365)
|
|
30
|
+
#: The longest signing window a verifier accepts by default, in seconds (BOND.md §2).
|
|
31
|
+
BOND_MAX_WINDOW_SECONDS = 3600
|
|
32
|
+
|
|
33
|
+
_OUTPOINT = re.compile(r"^[0-9a-f]{64}:(0|[1-9][0-9]{0,9})$")
|
|
34
|
+
_NONCE = re.compile(r"^[0-9a-f]{32}$")
|
|
35
|
+
_TIME = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$")
|
|
36
|
+
_SUBJECT = re.compile(r"^[\x21-\x7e]{1,256}$")
|
|
37
|
+
|
|
38
|
+
_BECH32 = "qpzry9x8gf2tvdw0s3jn54khce6mua7l"
|
|
39
|
+
_BECH32M_CONST = 0x2BC830A3
|
|
40
|
+
_B58 = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
|
|
41
|
+
_B58_VERSIONS = (0x00, 0x05, 0x6F, 0xC4)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _polymod(values: list[int]) -> int:
|
|
45
|
+
gen = (0x3B6A57B2, 0x26508E6D, 0x1EA119FA, 0x3D4233DD, 0x2A1462B3)
|
|
46
|
+
chk = 1
|
|
47
|
+
for v in values:
|
|
48
|
+
top = chk >> 25
|
|
49
|
+
chk = (chk & 0x1FFFFFF) << 5 ^ v
|
|
50
|
+
for i in range(5):
|
|
51
|
+
chk ^= gen[i] if ((top >> i) & 1) else 0
|
|
52
|
+
return chk
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _five_to_eight(words: list[int]) -> Optional[bytes]:
|
|
56
|
+
"""BIP-173 regrouping with strict padding: None when the padding is wrong."""
|
|
57
|
+
acc = bits = 0
|
|
58
|
+
out = bytearray()
|
|
59
|
+
for w in words:
|
|
60
|
+
acc = (acc << 5) | w
|
|
61
|
+
bits += 5
|
|
62
|
+
while bits >= 8:
|
|
63
|
+
bits -= 8
|
|
64
|
+
out.append((acc >> bits) & 0xFF)
|
|
65
|
+
if bits >= 5 or (acc << (8 - bits)) & 0xFF:
|
|
66
|
+
return None
|
|
67
|
+
return bytes(out)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _segwit_ok(address: str, hrp: str) -> bool:
|
|
71
|
+
if len(address) > 90 or address.lower() != address:
|
|
72
|
+
return False
|
|
73
|
+
sep = address.rfind("1")
|
|
74
|
+
if address[:sep] != hrp or len(address) - sep < 8:
|
|
75
|
+
return False
|
|
76
|
+
try:
|
|
77
|
+
data = [_BECH32.index(c) for c in address[sep + 1 :]]
|
|
78
|
+
except ValueError:
|
|
79
|
+
return False
|
|
80
|
+
const = _polymod([ord(c) >> 5 for c in hrp] + [0] + [ord(c) & 31 for c in hrp] + data)
|
|
81
|
+
words = data[:-6]
|
|
82
|
+
if not words:
|
|
83
|
+
return False
|
|
84
|
+
program = _five_to_eight(words[1:])
|
|
85
|
+
if program is None:
|
|
86
|
+
return False
|
|
87
|
+
if words[0] == 0:
|
|
88
|
+
return const == 1 and len(program) in (20, 32)
|
|
89
|
+
if words[0] == 1:
|
|
90
|
+
return const == _BECH32M_CONST and len(program) == 32
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _base58_ok(address: str) -> bool:
|
|
95
|
+
n = 0
|
|
96
|
+
for c in address:
|
|
97
|
+
i = _B58.find(c)
|
|
98
|
+
if i < 0:
|
|
99
|
+
return False
|
|
100
|
+
n = n * 58 + i
|
|
101
|
+
raw = b"\0" * (len(address) - len(address.lstrip("1"))) + n.to_bytes((n.bit_length() + 7) // 8, "big")
|
|
102
|
+
payload, check = raw[:-4], raw[-4:]
|
|
103
|
+
if len(payload) != 21 or payload[0] not in _B58_VERSIONS:
|
|
104
|
+
return False
|
|
105
|
+
return hashlib.sha256(hashlib.sha256(payload).digest()).digest()[:4] == check
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _address_ok(address: str) -> bool:
|
|
109
|
+
"""An address @orangecheck/bip322's decodeAddress accepts, checksum included."""
|
|
110
|
+
m = re.match(r"^(bc|tb|bcrt)1", address)
|
|
111
|
+
return _segwit_ok(address, m.group(1)) if m else _base58_ok(address)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(frozen=True)
|
|
115
|
+
class BondFields:
|
|
116
|
+
address: str
|
|
117
|
+
outpoint: str
|
|
118
|
+
subject: str
|
|
119
|
+
nonce: str
|
|
120
|
+
issued_at: str
|
|
121
|
+
expires_at: str
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _when(s: str) -> datetime:
|
|
125
|
+
# strptime refuses times that roll over, such as T24:00:00Z or February 30.
|
|
126
|
+
return datetime.strptime(s, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=timezone.utc)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _well_formed(f: BondFields) -> bool:
|
|
130
|
+
return bool(
|
|
131
|
+
_address_ok(f.address)
|
|
132
|
+
and _OUTPOINT.match(f.outpoint)
|
|
133
|
+
and _SUBJECT.match(f.subject)
|
|
134
|
+
and _NONCE.match(f.nonce)
|
|
135
|
+
and _TIME.match(f.issued_at)
|
|
136
|
+
and _TIME.match(f.expires_at)
|
|
137
|
+
and _when(f.expires_at) > _when(f.issued_at)
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def build_bond_message(fields: BondFields) -> str:
|
|
142
|
+
"""The exact text the holder signs (BOND.md §2)."""
|
|
143
|
+
if not _well_formed(fields):
|
|
144
|
+
raise ValueError("bond fields are malformed")
|
|
145
|
+
lines = [HEADER] + [f"{k}: {getattr(fields, k)}" for k in FIELDS] + [f"ack: {ACK}"]
|
|
146
|
+
return "\n".join(lines) + "\n"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def parse_bond_message(message: str) -> Optional[BondFields]:
|
|
150
|
+
"""The fields of a well-formed bond message, or None. Strict: §2 or nothing."""
|
|
151
|
+
if not message.endswith("\n") or "\r" in message:
|
|
152
|
+
return None
|
|
153
|
+
lines = message[:-1].split("\n")
|
|
154
|
+
if len(lines) != len(FIELDS) + 2 or lines[0] != HEADER or lines[-1] != f"ack: {ACK}":
|
|
155
|
+
return None
|
|
156
|
+
values = {}
|
|
157
|
+
for i, key in enumerate(FIELDS):
|
|
158
|
+
prefix = f"{key}: "
|
|
159
|
+
if not lines[i + 1].startswith(prefix):
|
|
160
|
+
return None
|
|
161
|
+
values[key] = lines[i + 1][len(prefix):]
|
|
162
|
+
fields = BondFields(**values)
|
|
163
|
+
try:
|
|
164
|
+
return fields if _well_formed(fields) and build_bond_message(fields) == message else None
|
|
165
|
+
except ValueError:
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def bond_id(message: str) -> str:
|
|
170
|
+
"""SHA-256 of the message bytes, lowercase hex (BOND.md §2)."""
|
|
171
|
+
return hashlib.sha256(message.encode("utf-8")).hexdigest()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def bond_nullifier(key: bytes, audience: str, outpoint: str) -> str:
|
|
175
|
+
"""
|
|
176
|
+
The per-audience nullifier for a coin (BOND.md §6), keyed with a secret only
|
|
177
|
+
the registry holds. Unkeyed, an audience could hash every unspent output and
|
|
178
|
+
find the coin.
|
|
179
|
+
"""
|
|
180
|
+
if len(key) < 32:
|
|
181
|
+
raise ValueError("bond nullifier key must be at least 32 bytes")
|
|
182
|
+
data = f"orangecheck-bond-nullifier\n{audience}\n{outpoint}".encode("utf-8")
|
|
183
|
+
return hmac.new(key, data, hashlib.sha256).hexdigest()
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def bond_floors(sats: int, age_blocks: int) -> dict[str, int]:
|
|
187
|
+
"""The largest floor each value meets, 0 when it meets none (BOND.md §5)."""
|
|
188
|
+
days = age_blocks // 144
|
|
189
|
+
return {
|
|
190
|
+
"sats_floor": max((f for f in BOND_SATS_FLOORS if sats >= f), default=0),
|
|
191
|
+
"days_floor": max((f for f in BOND_DAYS_FLOORS if days >= f), default=0),
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def verify_bond_signature(
|
|
196
|
+
message: str,
|
|
197
|
+
signature: str,
|
|
198
|
+
*,
|
|
199
|
+
subject: Optional[str] = None,
|
|
200
|
+
now: Optional[datetime] = None,
|
|
201
|
+
skew_seconds: int = 60,
|
|
202
|
+
max_window_seconds: int = BOND_MAX_WINDOW_SECONDS,
|
|
203
|
+
) -> tuple[bool, str]:
|
|
204
|
+
"""
|
|
205
|
+
Check a signed bond message: shape, subject, signing window, signature.
|
|
206
|
+
Returns ``(True, bond_id)`` or ``(False, reason)`` with BOND.md's reason
|
|
207
|
+
codes. Requires the ``[verify]`` extra for the signature.
|
|
208
|
+
"""
|
|
209
|
+
from .verify_sig import verify_bip322_signature
|
|
210
|
+
|
|
211
|
+
fields = parse_bond_message(message)
|
|
212
|
+
if fields is None:
|
|
213
|
+
return False, "bond_malformed"
|
|
214
|
+
if subject is not None and fields.subject != subject:
|
|
215
|
+
return False, "bond_subject_mismatch"
|
|
216
|
+
window = (_when(fields.expires_at) - _when(fields.issued_at)).total_seconds()
|
|
217
|
+
if window > max_window_seconds:
|
|
218
|
+
return False, "bond_window_too_long"
|
|
219
|
+
at = (now or datetime.now(timezone.utc)).timestamp()
|
|
220
|
+
if _when(fields.issued_at).timestamp() - skew_seconds > at:
|
|
221
|
+
return False, "bond_not_yet_valid"
|
|
222
|
+
if _when(fields.expires_at).timestamp() + skew_seconds < at:
|
|
223
|
+
return False, "bond_expired"
|
|
224
|
+
if not verify_bip322_signature(fields.address, message, signature):
|
|
225
|
+
return False, "sig_invalid"
|
|
226
|
+
return True, bond_id(message)
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "orangecheck"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.2.0"
|
|
8
8
|
description = "Python SDK for OrangeCheck — proof of Bitcoin stake + the OC Pledge primitive (commitments) for the open web."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""
|
|
2
|
+
A bond's address is read the way @orangecheck/bip322's decodeAddress reads it,
|
|
3
|
+
checksum included, so both SDKs give the same reason for the same message.
|
|
4
|
+
The verdicts below were produced by that function.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from orangecheck.bond import _address_ok
|
|
12
|
+
|
|
13
|
+
TS_VERDICTS = [
|
|
14
|
+
("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4", True),
|
|
15
|
+
("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t5", False),
|
|
16
|
+
("BC1QW508D6QEJXTDG4Y5R3ZARVARY0C5XW7KV8F3T4", False),
|
|
17
|
+
("bc1pmfr3p9j00pfxjh0zmgp99y8zftmd3s5pmedqhyptwy6lm87hf5sspknck9", True),
|
|
18
|
+
("bc1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3", True),
|
|
19
|
+
("tb1qw508d6qejxtdg4y5r3zarvary0c5xw7kxpjzsx", True),
|
|
20
|
+
("1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2", True),
|
|
21
|
+
("1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN3", False),
|
|
22
|
+
("3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy", True),
|
|
23
|
+
# Valid checksums over bad padding (v0 and v1).
|
|
24
|
+
("bc1qlllllllllllllllllllllllllllllllllp3sej9", False),
|
|
25
|
+
("bc1plllllllllllllllllllllllllllllllllllllllllllllllllllllnjr3m8", False),
|
|
26
|
+
# bech32m on a v0 program, and witness version 2.
|
|
27
|
+
("bc1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqs9wcxj", False),
|
|
28
|
+
("bc1zqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqupf6cj", False),
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@pytest.mark.parametrize("address,ok", TS_VERDICTS)
|
|
33
|
+
def test_address_agrees_with_typescript(address: str, ok: bool) -> None:
|
|
34
|
+
assert _address_ok(address) is ok
|
|
@@ -136,6 +136,63 @@ def test_bip322_signature(vector: dict) -> None:
|
|
|
136
136
|
)
|
|
137
137
|
|
|
138
138
|
|
|
139
|
+
# ─── bonds (BOND.md) ───────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@pytest.mark.parametrize("vector", vectors_of("bond_message"), ids=ids_for("bond_message"))
|
|
143
|
+
def test_bond_message(vector: dict) -> None:
|
|
144
|
+
"""The same eight lines and the same id as the TypeScript SDK."""
|
|
145
|
+
from orangecheck import BondFields, bond_id, build_bond_message, parse_bond_message
|
|
146
|
+
|
|
147
|
+
fields = BondFields(**vector["input"])
|
|
148
|
+
msg = build_bond_message(fields)
|
|
149
|
+
assert msg == vector["expected"]["canonical"]
|
|
150
|
+
assert bond_id(msg) == vector["expected"]["bond_id"]
|
|
151
|
+
assert parse_bond_message(msg) == fields
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@pytest.mark.parametrize("vector", vectors_of("bond_nullifier"), ids=ids_for("bond_nullifier"))
|
|
155
|
+
def test_bond_nullifier(vector: dict) -> None:
|
|
156
|
+
from orangecheck import bond_nullifier
|
|
157
|
+
|
|
158
|
+
inp = vector["input"]
|
|
159
|
+
key = bytes.fromhex(inp["key"])
|
|
160
|
+
got = [bond_nullifier(key, a, inp["outpoint"]) for a in inp["audiences"]]
|
|
161
|
+
assert got == vector["expected"]["nullifiers"]
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@pytest.mark.parametrize("vector", vectors_of("bond_floors"), ids=ids_for("bond_floors"))
|
|
165
|
+
def test_bond_floors(vector: dict) -> None:
|
|
166
|
+
from orangecheck import bond_floors
|
|
167
|
+
|
|
168
|
+
got = [bond_floors(c["sats"], c["age_blocks"]) for c in vector["input"]["cases"]]
|
|
169
|
+
assert got == vector["expected"]["floors"]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@pytest.mark.parametrize("vector", vectors_of("bond_signature"), ids=ids_for("bond_signature"))
|
|
173
|
+
def test_bond_signature(vector: dict) -> None:
|
|
174
|
+
from datetime import datetime
|
|
175
|
+
|
|
176
|
+
from orangecheck import verify_bip322_signature, verify_bond_signature
|
|
177
|
+
|
|
178
|
+
if verify_bip322_signature is None:
|
|
179
|
+
pytest.skip("install orangecheck[verify] to exercise bond signature vectors")
|
|
180
|
+
|
|
181
|
+
def at(s: str) -> datetime:
|
|
182
|
+
return datetime.fromisoformat(s.replace("Z", "+00:00"))
|
|
183
|
+
|
|
184
|
+
inp = vector["input"]
|
|
185
|
+
for i, b in enumerate(inp.get("bonds", [])):
|
|
186
|
+
ok, got = verify_bond_signature(b["message"], b["signature"], now=at(inp["now"]))
|
|
187
|
+
assert ok, got
|
|
188
|
+
assert got == vector["expected"]["bond_ids"][i]
|
|
189
|
+
for c in inp.get("cases", []):
|
|
190
|
+
assert verify_bond_signature(c["message"], c["signature"], now=at(c["now"])) == (
|
|
191
|
+
False,
|
|
192
|
+
c["reason"],
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
139
196
|
# ─── Cross-impl meta-check ─────────────────────────────────────────────────
|
|
140
197
|
|
|
141
198
|
|
|
@@ -150,5 +207,9 @@ def test_vector_set_complete() -> None:
|
|
|
150
207
|
"score_v0",
|
|
151
208
|
"reject",
|
|
152
209
|
"bip322_signature",
|
|
210
|
+
"bond_message",
|
|
211
|
+
"bond_signature",
|
|
212
|
+
"bond_nullifier",
|
|
213
|
+
"bond_floors",
|
|
153
214
|
}
|
|
154
|
-
assert len(VECTORS) >=
|
|
215
|
+
assert len(VECTORS) >= 28
|
|
@@ -113,5 +113,30 @@
|
|
|
113
113
|
"id": "tv23",
|
|
114
114
|
"category": "bip322_signature",
|
|
115
115
|
"description": "tampered signature (1 byte flipped) on an otherwise-valid tv21 payload — MUST NOT verify"
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
"id": "tv24",
|
|
119
|
+
"category": "bond_message",
|
|
120
|
+
"description": "bond message and its id (BOND.md §2)"
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
"id": "tv25",
|
|
124
|
+
"category": "bond_signature",
|
|
125
|
+
"description": "P2WPKH and P2TR bond signatures by private key 1, inside the signing window"
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
"id": "tv26",
|
|
129
|
+
"category": "bond_signature",
|
|
130
|
+
"description": "wrong signer, expired window, window over an hour, rolled-over time — MUST NOT verify"
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
"id": "tv27",
|
|
134
|
+
"category": "bond_nullifier",
|
|
135
|
+
"description": "one coin, two audiences, one registry key (BOND.md §6)"
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
"id": "tv28",
|
|
139
|
+
"category": "bond_floors",
|
|
140
|
+
"description": "floors instead of amount and age (BOND.md §5)"
|
|
116
141
|
}
|
|
117
142
|
]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "tv24",
|
|
3
|
+
"category": "bond_message",
|
|
4
|
+
"description": "bond message and its id (BOND.md §2)",
|
|
5
|
+
"input": {
|
|
6
|
+
"address": "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4",
|
|
7
|
+
"outpoint": "1bd631f9d28188be398bae26c018e93ec4d276d25d8f969b6cbc28eab13686c9:0",
|
|
8
|
+
"subject": "did:oc:0123456789abcdef0123456789abcdef",
|
|
9
|
+
"nonce": "00112233445566778899aabbccddeeff",
|
|
10
|
+
"issued_at": "2026-10-16T12:00:00Z",
|
|
11
|
+
"expires_at": "2026-10-16T12:10:00Z"
|
|
12
|
+
},
|
|
13
|
+
"expected": {
|
|
14
|
+
"canonical": "orangecheck-bond\naddress: bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4\noutpoint: 1bd631f9d28188be398bae26c018e93ec4d276d25d8f969b6cbc28eab13686c9:0\nsubject: did:oc:0123456789abcdef0123456789abcdef\nnonce: 00112233445566778899aabbccddeeff\nissued_at: 2026-10-16T12:00:00Z\nexpires_at: 2026-10-16T12:10:00Z\nack: I back this subject with this coin. It stays in my wallet.\n",
|
|
15
|
+
"bond_id": "c4a9465c3b161eccb18c479649ef93149ac38124def8c3b9c7552bb1915d129e"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "tv25",
|
|
3
|
+
"category": "bond_signature",
|
|
4
|
+
"description": "P2WPKH and P2TR bond signatures by private key 1 (public, holds nothing), checked inside the signing window",
|
|
5
|
+
"input": {
|
|
6
|
+
"now": "2026-10-16T12:05:00Z",
|
|
7
|
+
"bonds": [
|
|
8
|
+
{
|
|
9
|
+
"address": "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4",
|
|
10
|
+
"message": "orangecheck-bond\naddress: bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4\noutpoint: 1bd631f9d28188be398bae26c018e93ec4d276d25d8f969b6cbc28eab13686c9:0\nsubject: did:oc:0123456789abcdef0123456789abcdef\nnonce: 00112233445566778899aabbccddeeff\nissued_at: 2026-10-16T12:00:00Z\nexpires_at: 2026-10-16T12:10:00Z\nack: I back this subject with this coin. It stays in my wallet.\n",
|
|
11
|
+
"signature": "AkcwRAIgBtorUyg37InmZ8kY9+iWo7557g2YD5VsH0M+yHZke9wCIG0cnfgU0o+HXaTD1Tf7/uLi6+XWGpX32wMqIaHSNzxrASECeb5mfvncu6xVoGKVzocLBwKb/NstzijZWfKBWxb4F5g="
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"address": "bc1pmfr3p9j00pfxjh0zmgp99y8zftmd3s5pmedqhyptwy6lm87hf5sspknck9",
|
|
15
|
+
"message": "orangecheck-bond\naddress: bc1pmfr3p9j00pfxjh0zmgp99y8zftmd3s5pmedqhyptwy6lm87hf5sspknck9\noutpoint: 1bd631f9d28188be398bae26c018e93ec4d276d25d8f969b6cbc28eab13686c9:0\nsubject: did:oc:0123456789abcdef0123456789abcdef\nnonce: 00112233445566778899aabbccddeeff\nissued_at: 2026-10-16T12:00:00Z\nexpires_at: 2026-10-16T12:10:00Z\nack: I back this subject with this coin. It stays in my wallet.\n",
|
|
16
|
+
"signature": "AUHt6fHusZm0RTxT2rmOw/1lDkfxgzIANvN+V1K0/llB3JXApVUlzYZhC8Er3bQo6OKluAIvF/wDUKG43PvZZMnUAQ=="
|
|
17
|
+
}
|
|
18
|
+
]
|
|
19
|
+
},
|
|
20
|
+
"expected": {
|
|
21
|
+
"valid": true,
|
|
22
|
+
"bond_ids": [
|
|
23
|
+
"c4a9465c3b161eccb18c479649ef93149ac38124def8c3b9c7552bb1915d129e",
|
|
24
|
+
"5f7e7b49acab0088da4ef49b9b95a69ea5970190bf63659e3728b821efaa72a5"
|
|
25
|
+
]
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "tv26",
|
|
3
|
+
"category": "bond_signature",
|
|
4
|
+
"description": "the P2TR signature over the P2WPKH message MUST NOT verify; the P2WPKH bond after its window MUST be refused; a window over an hour MUST be refused even with a good signature; a time that rolls over (T24:00:00Z) is malformed",
|
|
5
|
+
"input": {
|
|
6
|
+
"cases": [
|
|
7
|
+
{
|
|
8
|
+
"now": "2026-10-16T12:05:00Z",
|
|
9
|
+
"message": "orangecheck-bond\naddress: bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4\noutpoint: 1bd631f9d28188be398bae26c018e93ec4d276d25d8f969b6cbc28eab13686c9:0\nsubject: did:oc:0123456789abcdef0123456789abcdef\nnonce: 00112233445566778899aabbccddeeff\nissued_at: 2026-10-16T12:00:00Z\nexpires_at: 2026-10-16T12:10:00Z\nack: I back this subject with this coin. It stays in my wallet.\n",
|
|
10
|
+
"signature": "AUHt6fHusZm0RTxT2rmOw/1lDkfxgzIANvN+V1K0/llB3JXApVUlzYZhC8Er3bQo6OKluAIvF/wDUKG43PvZZMnUAQ==",
|
|
11
|
+
"reason": "sig_invalid"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"now": "2026-10-16T12:20:00Z",
|
|
15
|
+
"message": "orangecheck-bond\naddress: bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4\noutpoint: 1bd631f9d28188be398bae26c018e93ec4d276d25d8f969b6cbc28eab13686c9:0\nsubject: did:oc:0123456789abcdef0123456789abcdef\nnonce: 00112233445566778899aabbccddeeff\nissued_at: 2026-10-16T12:00:00Z\nexpires_at: 2026-10-16T12:10:00Z\nack: I back this subject with this coin. It stays in my wallet.\n",
|
|
16
|
+
"signature": "AkcwRAIgBtorUyg37InmZ8kY9+iWo7557g2YD5VsH0M+yHZke9wCIG0cnfgU0o+HXaTD1Tf7/uLi6+XWGpX32wMqIaHSNzxrASECeb5mfvncu6xVoGKVzocLBwKb/NstzijZWfKBWxb4F5g=",
|
|
17
|
+
"reason": "bond_expired"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"now": "2026-10-16T12:05:00Z",
|
|
21
|
+
"message": "orangecheck-bond\naddress: bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4\noutpoint: 1bd631f9d28188be398bae26c018e93ec4d276d25d8f969b6cbc28eab13686c9:0\nsubject: did:oc:0123456789abcdef0123456789abcdef\nnonce: 00112233445566778899aabbccddeeff\nissued_at: 2026-10-16T12:00:00Z\nexpires_at: 2026-10-16T14:00:00Z\nack: I back this subject with this coin. It stays in my wallet.\n",
|
|
22
|
+
"signature": "AkcwRAIgCY8Rm6wdrZkbtup43Hjii70g+Hl1byYAVM+Z0405buACIAaE+P2t5Y9dH1fb9nCC3E8CrDVqEMi3j65l4Dh6UZMNASECeb5mfvncu6xVoGKVzocLBwKb/NstzijZWfKBWxb4F5g=",
|
|
23
|
+
"reason": "bond_window_too_long"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"now": "2026-10-16T12:05:00Z",
|
|
27
|
+
"message": "orangecheck-bond\naddress: bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4\noutpoint: 1bd631f9d28188be398bae26c018e93ec4d276d25d8f969b6cbc28eab13686c9:0\nsubject: did:oc:0123456789abcdef0123456789abcdef\nnonce: 00112233445566778899aabbccddeeff\nissued_at: 2026-10-16T12:00:00Z\nexpires_at: 2026-10-16T24:00:00Z\nack: I back this subject with this coin. It stays in my wallet.\n",
|
|
28
|
+
"signature": "AkcwRAIgSem/l3mNW3RuBevxUME7srZm4mY+iBVDwwGTLyz9VD4CIGERr4Ro/HGqOivXJvUatJcp0BgbOsJ+tf2fgqsKwEfIASECeb5mfvncu6xVoGKVzocLBwKb/NstzijZWfKBWxb4F5g=",
|
|
29
|
+
"reason": "bond_malformed"
|
|
30
|
+
}
|
|
31
|
+
]
|
|
32
|
+
},
|
|
33
|
+
"expected": {
|
|
34
|
+
"valid": false
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "tv27",
|
|
3
|
+
"category": "bond_nullifier",
|
|
4
|
+
"description": "one coin, two audiences, one registry key: the same input gives the same nullifier, different audiences unrelated ones (BOND.md §6). The key is a public test key; a registry keeps its own secret.",
|
|
5
|
+
"input": {
|
|
6
|
+
"key": "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f",
|
|
7
|
+
"outpoint": "1bd631f9d28188be398bae26c018e93ec4d276d25d8f969b6cbc28eab13686c9:0",
|
|
8
|
+
"audiences": [
|
|
9
|
+
"https://example.com",
|
|
10
|
+
"https://ochk.app"
|
|
11
|
+
]
|
|
12
|
+
},
|
|
13
|
+
"expected": {
|
|
14
|
+
"nullifiers": [
|
|
15
|
+
"f9f4ac1b66f2244dfd1ba70f2a8291bdf98e5aad5d7d828d79fbd7a4d188a755",
|
|
16
|
+
"a927f6884c62e98fe5802588d026d0a041b6c8f30cea817126daae965780531e"
|
|
17
|
+
]
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "tv28",
|
|
3
|
+
"category": "bond_floors",
|
|
4
|
+
"description": "floors an audience sees instead of amount and age (BOND.md §5); age in blocks, 144 per day",
|
|
5
|
+
"input": {
|
|
6
|
+
"cases": [
|
|
7
|
+
{
|
|
8
|
+
"sats": 999,
|
|
9
|
+
"age_blocks": 57600
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
"sats": 7397,
|
|
13
|
+
"age_blocks": 50256
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"sats": 150000,
|
|
17
|
+
"age_blocks": 4320
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"sats": 150000,
|
|
21
|
+
"age_blocks": 4319
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"sats": 25000000,
|
|
25
|
+
"age_blocks": 6
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
},
|
|
29
|
+
"expected": {
|
|
30
|
+
"floors": [
|
|
31
|
+
{
|
|
32
|
+
"sats_floor": 0,
|
|
33
|
+
"days_floor": 365
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"sats_floor": 1000,
|
|
37
|
+
"days_floor": 90
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"sats_floor": 100000,
|
|
41
|
+
"days_floor": 30
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
"sats_floor": 100000,
|
|
45
|
+
"days_floor": 7
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"sats_floor": 10000000,
|
|
49
|
+
"days_floor": 0
|
|
50
|
+
}
|
|
51
|
+
]
|
|
52
|
+
}
|
|
53
|
+
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|