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.
Files changed (60) hide show
  1. {orangecheck-2.0.0 → orangecheck-2.2.0}/CHANGELOG.md +33 -0
  2. {orangecheck-2.0.0 → orangecheck-2.2.0}/PKG-INFO +1 -1
  3. {orangecheck-2.0.0 → orangecheck-2.2.0}/orangecheck/__init__.py +24 -1
  4. orangecheck-2.2.0/orangecheck/bond.py +226 -0
  5. {orangecheck-2.0.0 → orangecheck-2.2.0}/pyproject.toml +1 -1
  6. orangecheck-2.2.0/tests/test_bond_address.py +34 -0
  7. {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/test_conformance.py +62 -1
  8. {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/vectors/index.json +25 -0
  9. orangecheck-2.2.0/tests/vectors/tv24.json +17 -0
  10. orangecheck-2.2.0/tests/vectors/tv25.json +27 -0
  11. orangecheck-2.2.0/tests/vectors/tv26.json +36 -0
  12. orangecheck-2.2.0/tests/vectors/tv27.json +19 -0
  13. orangecheck-2.2.0/tests/vectors/tv28.json +53 -0
  14. {orangecheck-2.0.0 → orangecheck-2.2.0}/.gitignore +0 -0
  15. {orangecheck-2.0.0 → orangecheck-2.2.0}/README.md +0 -0
  16. {orangecheck-2.0.0 → orangecheck-2.2.0}/orangecheck/__main__.py +0 -0
  17. {orangecheck-2.0.0 → orangecheck-2.2.0}/orangecheck/canonical.py +0 -0
  18. {orangecheck-2.0.0 → orangecheck-2.2.0}/orangecheck/client.py +0 -0
  19. {orangecheck-2.0.0 → orangecheck-2.2.0}/orangecheck/errors.py +0 -0
  20. {orangecheck-2.0.0 → orangecheck-2.2.0}/orangecheck/pledge/__init__.py +0 -0
  21. {orangecheck-2.0.0 → orangecheck-2.2.0}/orangecheck/pledge/bond.py +0 -0
  22. {orangecheck-2.0.0 → orangecheck-2.2.0}/orangecheck/pledge/canonical.py +0 -0
  23. {orangecheck-2.0.0 → orangecheck-2.2.0}/orangecheck/pledge/delegation.py +0 -0
  24. {orangecheck-2.0.0 → orangecheck-2.2.0}/orangecheck/pledge/envelopes.py +0 -0
  25. {orangecheck-2.0.0 → orangecheck-2.2.0}/orangecheck/pledge/resolution.py +0 -0
  26. {orangecheck-2.0.0 → orangecheck-2.2.0}/orangecheck/pledge/state.py +0 -0
  27. {orangecheck-2.0.0 → orangecheck-2.2.0}/orangecheck/py.typed +0 -0
  28. {orangecheck-2.0.0 → orangecheck-2.2.0}/orangecheck/top_level.py +0 -0
  29. {orangecheck-2.0.0 → orangecheck-2.2.0}/orangecheck/types.py +0 -0
  30. {orangecheck-2.0.0 → orangecheck-2.2.0}/orangecheck/verify_sig.py +0 -0
  31. {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/pledge/__init__.py +0 -0
  32. {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/pledge/test_authority.py +0 -0
  33. {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/pledge/test_delegation.py +0 -0
  34. {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/pledge/test_vectors.py +0 -0
  35. {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/pledge/test_wrap.py +0 -0
  36. {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/test_client.py +0 -0
  37. {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/test_client_failure_modes.py +0 -0
  38. {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/vectors/tv01.json +0 -0
  39. {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/vectors/tv02.json +0 -0
  40. {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/vectors/tv03.json +0 -0
  41. {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/vectors/tv04.json +0 -0
  42. {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/vectors/tv05.json +0 -0
  43. {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/vectors/tv06.json +0 -0
  44. {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/vectors/tv07.json +0 -0
  45. {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/vectors/tv08.json +0 -0
  46. {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/vectors/tv09.json +0 -0
  47. {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/vectors/tv10.json +0 -0
  48. {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/vectors/tv11.json +0 -0
  49. {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/vectors/tv12.json +0 -0
  50. {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/vectors/tv13.json +0 -0
  51. {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/vectors/tv14.json +0 -0
  52. {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/vectors/tv15.json +0 -0
  53. {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/vectors/tv16.json +0 -0
  54. {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/vectors/tv17.json +0 -0
  55. {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/vectors/tv18.json +0 -0
  56. {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/vectors/tv19.json +0 -0
  57. {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/vectors/tv20.json +0 -0
  58. {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/vectors/tv21.json +0 -0
  59. {orangecheck-2.0.0 → orangecheck-2.2.0}/tests/vectors/tv22.json +0 -0
  60. {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.0.0
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.0.0"
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.0.0"
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) >= 23
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