1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
//! Error variants for the md-codec wire-format codec.
use thiserror::Error;
/// Operator-context kind — where in the descriptor tree an operator appears.
/// Per SPEC v0.30 §11. Carried by [`Error::OperatorContextViolation`] to name
/// which tree-position a forbidden tag was encountered in.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ContextKind {
/// Top-level descriptor position (e.g., bare `PkK` as the descriptor root).
TopLevel,
/// Inside a `tr()` tap-script leaf (BIP-342 tapscript-only operators).
TapLeaf,
/// Inside a multi-family body (non-key tag among multi children).
MultiBody,
}
/// Errors produced by md-codec wire-format components.
#[derive(Debug, Error, PartialEq, Eq)]
pub enum Error {
/// A read of `requested` bits was attempted but only `available` bits remained.
#[error("attempted to read {requested} bits with only {available} bits remaining")]
BitStreamTruncated {
/// Number of bits the caller requested.
requested: usize,
/// Number of bits actually available in the stream.
available: usize,
},
/// Wire-format version field doesn't match v0.30 (=4). Returned when a
/// payload or chunk-header is read with a version value outside the
/// accepted v0.30 set. Per SPEC v0.30 §2.4 + §2.5 + §11.1.
#[error("wire-format version mismatch: got {got}, expected 4")]
WireVersionMismatch {
/// Version value parsed from the wire.
got: u8,
},
/// Header malformed in a way other than version mismatch — e.g., chunked-
/// flag inconsistent with caller context, or chunk-header internal field
/// out of range. Per SPEC v0.30 §11.1.
#[error("malformed header: {detail}")]
MalformedHeader {
/// Free-form description of the malformedness.
detail: String,
},
/// Path depth exceeds MAX_PATH_COMPONENTS (15).
#[error("path depth {got} exceeds maximum {max}")]
PathDepthExceeded {
/// Actual depth of the path.
got: usize,
/// Maximum allowed depth (15).
max: usize,
},
/// Key count `n` out of range. Per SPEC v0.30 §4: `1 ≤ n ≤ 32`.
#[error("key count {n} out of range; require 1 ≤ n ≤ 32")]
KeyCountOutOfRange {
/// Actual key count provided.
n: u8,
},
/// Divergent path count doesn't match key count.
#[error("divergent path count {got} does not match key count {n}")]
DivergentPathCountMismatch {
/// Expected key count.
n: u8,
/// Actual path count provided.
got: usize,
},
/// Multipath alt-count out of range. Per SPEC v0.30 §8: `2 ≤ count ≤ 9`.
#[error("multipath alt-count {got} out of range; require 2 ≤ count ≤ 9")]
AltCountOutOfRange {
/// Provided alt-count.
got: usize,
},
/// Tag value outside the allocated v0.30 set: 6-bit primary in reserved
/// range 0x24..=0x3E, or extension prefix 0x3F followed by an unrecognized
/// 4-bit subcode 0x00..=0x0F (the entire extension subspace is reserved
/// in v0.30). `primary` carries the raw 6-bit value read off the wire
/// (0x3F for extension-subspace failures); the 4-bit subcode is consumed
/// but not reported. Per SPEC v0.30 §3.2 and §11.1.
#[error("tag value 0x{primary:02x} out of range")]
TagOutOfRange {
/// The raw 6-bit primary value read off the wire.
primary: u8,
},
/// Threshold `k` out of range. Per SPEC v0.30 §4: `1 ≤ k ≤ 32`.
#[error("threshold k={k} out of range; require 1 ≤ k ≤ 32")]
ThresholdOutOfRange {
/// Provided k value.
k: u8,
},
/// Variable-arity child count out of range. Per SPEC v0.30 §4: `1 ≤ count ≤ 32`.
#[error("child count {count} out of range; require 1 ≤ count ≤ 32")]
ChildCountOutOfRange {
/// Provided child count.
count: usize,
},
/// k > n in k-of-n threshold/multisig.
#[error("threshold k={k} exceeds child count n={n}; require k ≤ n")]
KGreaterThanN {
/// Threshold k.
k: u8,
/// Child count n.
n: usize,
},
/// TLV ordering violation: a TLV tag was followed by a smaller-or-equal tag.
#[error(
"TLV ordering violation: tag 0x{prev:02x} followed by 0x{current:02x}; require ascending"
)]
TlvOrderingViolation {
/// Previous tag value.
prev: u8,
/// Current tag value.
current: u8,
},
/// Placeholder index in TLV entry exceeds key count n.
#[error("placeholder index {idx} out of range; require idx < n={n}")]
PlaceholderIndexOutOfRange {
/// Provided index.
idx: u8,
/// Key count n.
n: u8,
},
/// Per-`@N` override entries within a TLV must be in ascending `@N`-index order.
#[error("override ordering violation: @{prev} followed by @{current}; require ascending")]
OverrideOrderViolation {
/// Previous index.
prev: u8,
/// Current index.
current: u8,
},
/// TLV entry has zero entries; encoder MUST omit empty TLVs per spec §7.5.
#[error("TLV entry tag 0x{tag:02x} has empty payload; encoder MUST omit empty TLVs")]
EmptyTlvEntry {
/// Tag of the empty entry.
tag: u8,
},
/// TLV length exceeds remaining bits in stream.
#[error("TLV length {length} exceeds remaining bits {remaining}")]
TlvLengthExceedsRemaining {
/// Declared length.
length: usize,
/// Available bits.
remaining: usize,
},
/// Placeholder @i was not referenced anywhere in the tree (BIP 388 well-formedness).
#[error("placeholder @{idx} not referenced in tree; n={n}")]
PlaceholderNotReferenced {
/// The unreferenced placeholder index.
idx: u8,
/// Key count.
n: u8,
},
/// First-occurrence ordering violated (BIP 388 well-formedness).
#[error(
"placeholder first-occurrence ordering violated: expected first={expected_first}, got first={got_first}"
)]
PlaceholderFirstOccurrenceOutOfOrder {
/// Expected placeholder index in canonical first-occurrence position.
expected_first: u8,
/// Actual placeholder index encountered first.
got_first: u8,
},
/// All multipaths in a template must share the same alt-count.
#[error("multipath alt-count mismatch: expected {expected}, got {got}")]
MultipathAltCountMismatch {
/// Expected alt-count.
expected: usize,
/// Mismatched alt-count.
got: usize,
},
/// Tap-script-tree leaf has a tag that is forbidden per spec §6.3.1.
#[error("forbidden tap-script-tree leaf tag: 0x{tag:02x}")]
ForbiddenTapTreeLeaf {
/// Primary 6-bit tag code (bytecode space) of the forbidden leaf.
tag: u8,
},
/// Operator appears in a forbidden context per SPEC v0.30 §11.
/// `TopLevel` is enforced decoder-side at `decode_payload`; `TapLeaf` is
/// covered by the narrower [`Error::ForbiddenTapTreeLeaf`]; `MultiBody` is
/// structurally unreachable post-v0.30 Phase C (multi-family bodies carry
/// raw kiw-bit indices, not child tags).
#[error("operator {tag:?} not allowed in context {context:?}")]
OperatorContextViolation {
/// The offending operator tag.
tag: crate::tag::Tag,
/// Which tree-position the tag is forbidden in.
context: ContextKind,
},
/// Chunk count out of range. Per SPEC v0.30 §2.5: `1 ≤ count ≤ 64`.
#[error("chunk count {count} out of range; require 1 ≤ count ≤ 64")]
ChunkCountOutOfRange {
/// Provided count.
count: u8,
},
/// Chunk index ≥ count; require index < count.
#[error("chunk index {index} ≥ count {count}")]
ChunkIndexOutOfRange {
/// Provided index.
index: u8,
/// Provided count.
count: u8,
},
/// Chunk-set-id exceeds 20-bit range.
#[error("chunk-set-id 0x{id:x} exceeds 20-bit range")]
ChunkSetIdOutOfRange {
/// Provided ID.
id: u32,
},
/// Chunk header missing chunked-flag. Per SPEC v0.30 §2.2: bit 0 of the
/// first 5-bit symbol of a chunked payload is the chunked-flag (followed
/// by the 4-bit version field); it MUST be 1 in every chunk header.
#[error("chunk header chunked-flag missing; per SPEC §2.2 bit 0 of the first symbol must be 1")]
ChunkHeaderChunkedFlagMissing,
/// Encoding requires more chunks than the spec maximum (64).
#[error("encoding requires {needed} chunks; max is 64 per spec §9.8")]
ChunkCountExceedsMax {
/// Number of chunks needed.
needed: usize,
},
/// Codex32 decode error (HRP mismatch, alphabet violation, BCH verification failure).
#[error("codex32 decode error: {0}")]
Codex32DecodeError(String),
/// Codex32 encode error (BCH layer failure).
#[error("codex32 encode error: {0}")]
Codex32EncodeError(String),
/// Chunk set is empty (no strings provided to reassemble).
#[error("chunk set is empty (no strings provided)")]
ChunkSetEmpty,
/// Chunks in the set disagree on version, chunk-set-id, or count.
#[error("chunks in the set disagree on version, chunk-set-id, or count")]
ChunkSetInconsistent,
/// Chunk set incomplete: got fewer chunks than `expected`.
#[error("chunk set incomplete: got {got} chunks, expected {expected}")]
ChunkSetIncomplete {
/// Provided chunk count.
got: usize,
/// Expected chunk count.
expected: usize,
},
/// Chunk index gap: expected index N, got M.
#[error("chunk index gap: expected index {expected}, got {got}")]
ChunkIndexGap {
/// Expected index in the sequence.
expected: u8,
/// Actual index encountered.
got: u8,
},
/// Chunk-set-id mismatch between expected and reassembled-then-derived.
#[error("chunk-set-id mismatch: expected 0x{expected:x}, derived 0x{derived:x}")]
ChunkSetIdMismatch {
/// Expected (from chunks).
expected: u32,
/// Derived (from reassembled payload).
derived: u32,
},
/// LP4-ext varint value exceeds single-extension payload range (29 bits).
#[error("varint value {value} exceeds single-extension range (max 2^29 - 1)")]
VarintOverflow {
/// The offending value.
value: u32,
},
/// A non-canonical wrapper has no explicit origin path for some `@N`,
/// either via `OriginPathOverrides` or a populated `path_decl` entry,
/// and `canonical_origin(&d.tree)` is `None`. Per spec v0.13 §6.3.
#[error("non-canonical wrapper requires explicit origin for @{idx}, but none provided")]
MissingExplicitOrigin {
/// The placeholder index for which an explicit origin is required.
idx: u8,
},
/// `presence_byte` had non-zero reserved bits (bits 2..7) inside a
/// `WalletPolicyId` canonical-record preimage. Per spec v0.13 §5.3:
/// encoders MUST set reserved bits to 0 and decoders MUST reject
/// inputs with non-zero reserved bits. v0.13's encoder masks reserved
/// bits explicitly when building the hash preimage; the helper
/// [`crate::identity::validate_presence_byte`] enforces the
/// decoder-side contract for canonical-record consumers.
#[error("WalletPolicyId presence_byte has non-zero reserved bits: 0x{reserved_bits:02x}")]
InvalidPresenceByte {
/// The reserved-bit field (bits 2..7) of the offending presence byte.
reserved_bits: u8,
},
/// A `Pubkeys` TLV entry's 33-byte compressed-pubkey field (bytes
/// 32..65 of the 65-byte xpub payload) failed to parse as a valid
/// secp256k1 point. The 32-byte chain code prefix is unvalidated.
/// Per spec v0.13 §6.4.
#[error("invalid xpub bytes for @{idx}: pubkey field is not a valid secp256k1 point")]
InvalidXpubBytes {
/// The placeholder index whose xpub failed to parse.
idx: u8,
},
/// Address derivation requires a populated `Pubkeys` TLV entry for
/// every `@N`; this descriptor is missing one (template-only or
/// partial-keys mode). v0.14+ derivation surface only.
#[error(
"missing xpub for @{idx}; address derivation requires wallet-policy mode with all @N populated"
)]
MissingPubkey {
/// The placeholder index whose xpub is absent.
idx: u8,
},
/// `Descriptor::derive_address` was called with a `chain` index
/// outside the use-site multipath alt-count (or non-zero when no
/// multipath is present).
#[error("chain index {chain} out of range; use-site multipath alt-count is {alt_count}")]
ChainIndexOutOfRange {
/// The provided chain index.
chain: u32,
/// The number of alternatives in the use-site multipath (`0` when
/// no multipath component is present).
alt_count: usize,
},
/// Address derivation requires non-hardened use-site components,
/// but this descriptor's use-site path declares a hardened
/// alternative or hardened wildcard. BIP 32 forbids hardened
/// derivation from a public key, so an xpub-only restore cannot
/// produce addresses for this wallet.
#[error(
"hardened public-key derivation: use-site path requires hardened component, which BIP 32 forbids on xpub-only restore"
)]
HardenedPublicDerivation,
/// Address derivation failed at the miniscript layer (or in the
/// AST → miniscript converter). Carries a free-form `detail` string
/// describing the underlying error — typically a `miniscript::Error`,
/// a `Tr`/`Wsh` constructor failure (type-check / context error), or
/// an arity/context mismatch raised by the converter.
#[error("address derivation failed: {detail}")]
AddressDerivationFailed {
/// Free-form description of the underlying failure.
detail: String,
},
/// Inside a `tr()` body, `is_nums = false` was paired with a `key_index`
/// out of range (`key_index >= n`). Per SPEC v0.30 §7 + §11: the
/// placeholder-index range is `0..n` strictly; the v0.x NUMS sentinel
/// slot at `key_index = n` is gone (NUMS is now flag-driven via
/// `Body::Tr.is_nums`). Raised by `validate_placeholder_usage` when the
/// in-`tr()` overflow condition is hit.
#[error("NUMS sentinel conflict: is_nums=false with key_index out of range (SPEC §7 §11)")]
NUMSSentinelConflict,
/// Decode-side recursion depth exceeded the hardening cap.
/// `read_node` calls itself recursively for tags with child bodies
/// (`Tag::Sh`, `Tag::AndV`, `Tag::TapTree`, `Tag::Multi`, `Tag::Tr`,
/// etc.); a hostile wire payload nesting these tags arbitrarily deep
/// would blow the Rust stack. The cap is shared across all recursive
/// tags as a generic anti-DOS hardening bound. v0.19 introduced.
#[error("decode recursion depth {depth} exceeded maximum {max}")]
DecodeRecursionDepthExceeded {
/// Current recursion depth at which the cap fired.
depth: u8,
/// Maximum allowed depth.
max: u8,
},
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tag::Tag;
/// SPEC v0.30 §11: `OperatorContextViolation` carries the offending tag
/// + a `ContextKind` discriminator. Pins the type shape and Display
/// output against future drift; does NOT claim live wire reachability
/// (see FOLLOWUP `v0.30-phase-g-operator-context-violation-unwired`).
#[test]
fn operator_context_violation_constructs() {
let err = Error::OperatorContextViolation {
tag: Tag::Multi,
context: ContextKind::MultiBody,
};
let s = err.to_string();
assert!(s.contains("Multi"), "Display must mention tag: {s}");
assert!(s.contains("MultiBody"), "Display must mention context: {s}");
}
/// SPEC v0.30 §7 + §11: `NUMSSentinelConflict` Display pins the SPEC-cite
/// substring so the doc-comment + format string don't silently drift.
#[test]
fn nums_sentinel_conflict_display() {
let s = Error::NUMSSentinelConflict.to_string();
assert!(s.contains("§7"), "Display must cite SPEC §7: {s}");
assert!(s.contains("§11"), "Display must cite SPEC §11: {s}");
}
}