Skip to main content

kascov_decode/
lib.rs

1//! Name Kaspa covenant programs from their bytes.
2//!
3//! This is the decoder behind every name kascov.io shows. A covenant's
4//! output commits to a program by hash (P2SH), and the spend that consumes
5//! it reveals the program as the last push of its signature script. Given
6//! those bytes, this crate says what the program is: which SilverScript or
7//! Argent build, which launchpad, market or token family, and what sits in
8//! its labelled fields. It reads nothing but the bytes it is given: no node,
9//! no network, no database.
10//!
11//! ```
12//! use kascov_decode::{p2sh_reveal, Registry};
13//!
14//! // a KaspaCom token program on testnet-10, and the signature script of
15//! // the spend that revealed it (fixtures/PROVENANCE.md says where each
16//! // came from)
17//! let program = include_bytes!("../fixtures/recovery/kcom_2671_parent_56fc521e_0.bin");
18//! let spend = include_bytes!("../fixtures/recovery/kcom_witness_7d5f35f0.bin");
19//!
20//! let decoded = Registry::default().decode(0, program);
21//! assert_eq!(decoded.template, Some("KaspaCom · token"));
22//!
23//! // the output that spend consumed commits to the program's BLAKE2b-256
24//! let hash = blake2b_simd::Params::new().hash_length(32).hash(program);
25//! let spk = [&[0xaa, 0x20][..], hash.as_bytes(), &[0x87]].concat();
26//! assert_eq!(p2sh_reveal(&spk, spend).as_deref(), Some(&program[..]));
27//! ```
28//!
29//! Where to start:
30//!
31//! - [`Registry`] tries each template decoder in turn. When none matches,
32//!   the always-correct fallback is a plain opcode disassembly.
33//! - [`report`] gives the answers of the `kascov-decode` command as JSON.
34//! - [`p2sh_reveal`] checks a spend's revealed program against the output
35//!   it spends, the same test kascov runs on every spend.
36//! - [`argent`] recognizes programs Argent generated and computes the
37//!   template hash that names their build.
38//! - [`kcc20`] and [`kcc0020`] read token cells; [`kcc1`] holds the KCC-0001
39//!   byte layouts and hashes they build on.
40//! - [`disasm`] is the opcode table and disassembler, covenant and ZK
41//!   opcodes included.
42
43pub mod argent;
44pub mod disasm;
45pub mod kcc1;
46pub mod kcc0020;
47pub mod kcc20;
48pub mod observed;
49pub mod report;
50pub mod vesting;
51
52use disasm::{disassemble, Instruction, OpGroup};
53use std::ops::Range;
54
55/// A labeled state field extracted by a template decoder.
56#[derive(Clone, Debug, serde::Serialize)]
57pub struct Field {
58    pub name: &'static str,
59    #[serde(serialize_with = "hex_ser")]
60    pub value: Vec<u8>,
61}
62
63fn hex_ser<S: serde::Serializer>(bytes: &[u8], s: S) -> Result<S::Ok, S::Error> {
64    s.serialize_str(&hex::encode(bytes))
65}
66
67/// What a decoder could make of a covenant state script.
68#[derive(Clone, Debug, serde::Serialize)]
69pub struct Decoded {
70    /// Name of the decoder that matched ("disasm" for the fallback).
71    pub decoder: &'static str,
72    pub instructions: Vec<Instruction>,
73    pub truncated: bool,
74    /// Data pushes, in order — for known templates these are the state fields.
75    pub pushes: Vec<Vec<u8>>,
76    pub uses_covenant_ops: bool,
77    pub uses_zk_ops: bool,
78    /// Best-effort proving system guessed from the ZK arguments, when the
79    /// script uses `OpZkPrecompile` (see `zk_system`). `None` when there are
80    /// no ZK ops or the shape is too ambiguous to call.
81    pub zk_system: Option<&'static str>,
82    /// Recognized template name, when a template decoder matched.
83    pub template: Option<&'static str>,
84    /// Which compiler generation produced the build the matching skeleton
85    /// was cut from (see [`SILVERSCRIPT_GENERATIONS`]), when the skeleton
86    /// declares one. One family name covers every generation; this is the
87    /// only place the generation is stated, and only for builds a skeleton
88    /// proves.
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub generation: Option<&'static str>,
91    /// Labeled constructor/state fields, when the template names them.
92    pub fields: Vec<Field>,
93}
94
95pub trait StateDecoder: Send + Sync {
96    fn name(&self) -> &'static str;
97    /// Return a decode if this decoder recognizes the script template.
98    fn decode(&self, spk_version: u16, script: &[u8]) -> Option<Decoded>;
99}
100
101/// Fallback: full disassembly. Always succeeds.
102pub struct DisasmDecoder;
103
104fn base_decode(name: &'static str, script: &[u8]) -> Decoded {
105    let (instructions, truncated) = disassemble(script);
106    Decoded {
107        decoder: name,
108        pushes: instructions.iter().filter_map(|i| i.data.clone()).collect(),
109        uses_covenant_ops: instructions.iter().any(|i| i.group == OpGroup::Covenant),
110        uses_zk_ops: instructions.iter().any(|i| i.group == OpGroup::Zk),
111        zk_system: zk_system_from(&instructions),
112        instructions,
113        truncated,
114        template: None,
115        generation: None,
116        fields: vec![],
117    }
118}
119
120/// Guess which zero-knowledge proving system a covenant script hands to
121/// `OpZkPrecompile` (KIP-16, opcode `0xa6`). Best effort: the verifier pops a
122/// verifying key, a proof, and public inputs off the stack, so the data
123/// pushes in the program encode those shapes. We key on the two systems'
124/// very different proof sizes and prefer `None` over a shaky guess.
125///
126///   * **Groth16** (BN254 / BLS12-381) has a fixed, tiny proof — three curve
127///     points, 128–256 bytes depending on curve and compression — and its
128///     public inputs are 32-byte field elements. The verifying key is a
129///     handful of 64-byte G1 / 96–128-byte G2 points. No single push reaches
130///     STARK scale.
131///   * **RISC Zero** seals are STARK receipts: kilobytes of proof data plus a
132///     32-byte image id. A push of >= 1 KiB feeding the precompile is the tell.
133///   * A push between those bands (300–1023 bytes) is bigger than any Groth16
134///     encoding but below STARK scale — some succinct system we can't
135///     attribute further, labeled "succinct proof (inferred)".
136///
137/// Bare 32/64-byte pushes on their own are too generic to attribute, so those
138/// yield `None` (as does the 257–299 byte gap just above the Groth16 band).
139pub fn zk_system(script: &[u8]) -> Option<&'static str> {
140    let (instructions, _) = disassemble(script);
141    zk_system_from(&instructions)
142}
143
144fn zk_system_from(instructions: &[Instruction]) -> Option<&'static str> {
145    // Only meaningful for scripts that actually invoke the ZK verifier.
146    if !instructions.iter().any(|i| i.group == OpGroup::Zk) {
147        return None;
148    }
149    let sizes: Vec<usize> = instructions
150        .iter()
151        .filter_map(|i| i.data.as_ref().map(|d| d.len()))
152        .collect();
153    // STARK-scale seal → RISC Zero.
154    if sizes.iter().any(|&n| n >= 1024) {
155        return Some("risc0");
156    }
157    // Groth16 proof band: three curve points, ~128 (compressed) up to 256
158    // (uncompressed). A push in this range, with nothing STARK-scale present,
159    // reads as Groth16.
160    if sizes.iter().any(|&n| (128..=256).contains(&n)) {
161        return Some("groth16");
162    }
163    // Above every Groth16 encoding but below STARK scale: some succinct
164    // system's proof, unattributable beyond that.
165    if sizes.iter().any(|&n| (300..1024).contains(&n)) {
166        return Some("succinct proof (inferred)");
167    }
168    None
169}
170
171impl StateDecoder for DisasmDecoder {
172    fn name(&self) -> &'static str {
173        "disasm"
174    }
175    fn decode(&self, _spk_version: u16, script: &[u8]) -> Option<Decoded> {
176        Some(base_decode(self.name(), script))
177    }
178}
179
180/// `<push 32/33 bytes> OpCheckSig` — the plain pay-to-pubkey state carried by
181/// most covenants observed on TN10 (and the [[Covenant Lab]] ones).
182pub struct P2pkStateDecoder;
183
184impl StateDecoder for P2pkStateDecoder {
185    fn name(&self) -> &'static str {
186        "p2pk-state"
187    }
188    fn decode(&self, _spk_version: u16, script: &[u8]) -> Option<Decoded> {
189        let ok = matches!(script.len(), 34 | 35)
190            && script[0] as usize == script.len() - 2
191            && script[script.len() - 1] == 0xac;
192        if !ok {
193            return None;
194        }
195        let mut d = base_decode(self.name(), script);
196        d.template = Some("p2pk state");
197        d.fields = vec![Field {
198            name: "owner_pubkey",
199            value: script[1..script.len() - 1].to_vec(),
200        }];
201        Some(d)
202    }
203}
204
205/// `OpBlake2b <32-byte hash> OpEqual` — a P2SH commitment: the program is
206/// revealed at spend time (see `p2sh_reveal`).
207pub struct P2shCommitmentDecoder;
208
209impl StateDecoder for P2shCommitmentDecoder {
210    fn name(&self) -> &'static str {
211        "p2sh-commitment"
212    }
213    fn decode(&self, _spk_version: u16, script: &[u8]) -> Option<Decoded> {
214        let hash = p2sh_hash(script)?.to_vec();
215        let mut d = base_decode(self.name(), script);
216        d.template = Some("p2sh commitment");
217        d.fields = vec![Field {
218            name: "program_hash",
219            value: hash,
220        }];
221        Some(d)
222    }
223}
224
225/// One position in a compiled-contract skeleton. SilverScript inlines
226/// constructor arguments at their use sites (an argument can appear several
227/// times mid-script), so templates are matched on the disassembled
228/// instruction stream: fixed opcodes and constant pushes must be identical,
229/// argument slots accept any push and yield labeled fields.
230enum SkelItem {
231    /// A non-push instruction that must match exactly.
232    Op(u8),
233    /// A push whose bytes are part of the template itself. `raw` keeps the
234    /// original encoding so re-emitting a contract is byte-identical even
235    /// where the compiler chose a non-minimal push.
236    ConstPush { value: Vec<u8>, raw: Vec<u8> },
237    /// A push carrying a constructor argument.
238    Slot(&'static str),
239}
240
241/// Canonical push encoding — the bytes the SilverScript compiler's
242/// ScriptBuilder emits for a pushed value. Mirrors `encodePush` in
243/// `web/disasm.js`; used to re-encode argument slots when emitting a contract.
244pub fn encode_push(value: &[u8]) -> Vec<u8> {
245    match value {
246        [] => vec![0x00],                              // OpFalse
247        [0x81] => vec![0x4f],                          // Op1Negate
248        [v] if (1..=16).contains(v) => vec![0x50 + v], // Op1..Op16
249        _ if value.len() <= 75 => {
250            let mut out = Vec::with_capacity(value.len() + 1);
251            out.push(value.len() as u8);
252            out.extend_from_slice(value);
253            out
254        }
255        _ if value.len() <= 0xff => {
256            let mut out = vec![0x4c, value.len() as u8];
257            out.extend_from_slice(value);
258            out
259        }
260        _ if value.len() <= 0xffff => {
261            let mut out = vec![0x4d, (value.len() & 0xff) as u8, (value.len() >> 8) as u8];
262            out.extend_from_slice(value);
263            out
264        }
265        _ => {
266            let n = value.len() as u32;
267            let mut out = vec![
268                0x4e,
269                (n & 0xff) as u8,
270                (n >> 8 & 0xff) as u8,
271                (n >> 16 & 0xff) as u8,
272                (n >> 24) as u8,
273            ];
274            out.extend_from_slice(value);
275            out
276        }
277    }
278}
279
280pub struct Skeleton {
281    pub name: &'static str,
282    /// The compiler generation whose builds this skeleton describes, when
283    /// the dumps state one (the SilverScript example contracts are cut once
284    /// per generation, under one family name). `None` for skeletons derived
285    /// from observed on-chain instances, whose compiler is not known.
286    pub generation: Option<&'static str>,
287    items: Vec<SkelItem>,
288    /// Field labels in constructor order (for display ordering).
289    param_order: Vec<&'static str>,
290}
291
292/// A push instruction's value, whether it's a data push or a small-int
293/// opcode (`OpFalse`/`Op1Negate`/`Op1..Op16`), in script-number encoding.
294fn push_value(inst: &Instruction) -> Option<Vec<u8>> {
295    if let Some(data) = &inst.data {
296        return Some(data.clone());
297    }
298    match inst.opcode {
299        0x00 => Some(vec![]),
300        0x4f => Some(vec![0x81]),
301        0x51..=0x60 => Some(vec![inst.opcode - 0x50]),
302        _ => None,
303    }
304}
305
306fn is_push(inst: &Instruction) -> bool {
307    inst.group == OpGroup::Push
308}
309
310impl Skeleton {
311    /// Derive a skeleton from two builds of the same contract with different
312    /// sentinel arguments. Instructions must align one-to-one: equal
313    /// non-push opcodes stay fixed, equal pushes become constants, and
314    /// differing pushes become slots — labeled by looking the first build's
315    /// value up in `sentinels` (constructor order).
316    pub fn derive(
317        name: &'static str,
318        a: &[u8],
319        b: &[u8],
320        sentinels: &[(&'static str, Vec<u8>)],
321    ) -> Option<Skeleton> {
322        let (ia, ta) = disassemble(a);
323        let (ib, tb) = disassemble(b);
324        if ta || tb || ia.len() != ib.len() {
325            return None;
326        }
327        let mut items = Vec::with_capacity(ia.len());
328        for (i, (x, y)) in ia.iter().zip(&ib).enumerate() {
329            match (is_push(x), is_push(y)) {
330                (false, false) => {
331                    if x.opcode != y.opcode {
332                        return None;
333                    }
334                    items.push(SkelItem::Op(x.opcode));
335                }
336                (true, true) => {
337                    let vx = push_value(x)?;
338                    let vy = push_value(y)?;
339                    if vx == vy {
340                        // raw span of this push in dump A, for byte-perfect emit
341                        let end = ia.get(i + 1).map_or(a.len(), |n| n.offset);
342                        items.push(SkelItem::ConstPush {
343                            value: vx,
344                            raw: a[x.offset..end].to_vec(),
345                        });
346                    } else {
347                        let (label, _) = sentinels.iter().find(|(_, s)| *s == vx)?;
348                        items.push(SkelItem::Slot(label));
349                    }
350                }
351                _ => return None,
352            }
353        }
354        Some(Skeleton {
355            name,
356            generation: None,
357            items,
358            param_order: sentinels.iter().map(|(l, _)| *l).collect(),
359        })
360    }
361
362    /// Stamp the compiler generation these dumps came from. A family name
363    /// stays one name across generations; the stamp is what lets a reader
364    /// say which compiler cut the build it matched.
365    pub fn with_generation(mut self, generation: &'static str) -> Self {
366        self.generation = Some(generation);
367        self
368    }
369
370    /// Derive a skeleton from two or more distinct on-chain instances of the
371    /// same compiled contract (no sentinels available — the arguments are
372    /// whatever the deployers used). Instructions must align one-to-one
373    /// across every instance: equal non-push opcodes stay fixed, pushes that
374    /// agree everywhere become constants, and pushes that differ anywhere
375    /// become slots. Slots are labeled in first-occurrence order; two slot
376    /// positions whose values agree in *every* instance are the same inlined
377    /// argument and share one label (and `match_script` will keep enforcing
378    /// that they agree). `labels` must name exactly the distinct slots.
379    pub fn derive_observed(
380        name: &'static str,
381        instances: &[&[u8]],
382        labels: &[&'static str],
383    ) -> Option<Skeleton> {
384        if instances.len() < 2 {
385            return None;
386        }
387        let mut streams = Vec::with_capacity(instances.len());
388        for bytes in instances {
389            let (insts, truncated) = disassemble(bytes);
390            if truncated
391                || streams
392                    .first()
393                    .is_some_and(|f: &Vec<Instruction>| f.len() != insts.len())
394            {
395                return None;
396            }
397            streams.push(insts);
398        }
399        let first = &streams[0];
400        let mut items = Vec::with_capacity(first.len());
401        // Distinct slots seen so far, as their value-vector across instances.
402        let mut slots: Vec<Vec<Vec<u8>>> = Vec::new();
403        for (i, inst) in first.iter().enumerate() {
404            if !is_push(inst) {
405                if streams
406                    .iter()
407                    .any(|s| is_push(&s[i]) || s[i].opcode != inst.opcode)
408                {
409                    return None;
410                }
411                items.push(SkelItem::Op(inst.opcode));
412                continue;
413            }
414            let vector = streams
415                .iter()
416                .map(|s| push_value(&s[i]))
417                .collect::<Option<Vec<_>>>()?;
418            if vector.iter().all(|v| *v == vector[0]) {
419                let end = first.get(i + 1).map_or(instances[0].len(), |n| n.offset);
420                items.push(SkelItem::ConstPush {
421                    value: vector[0].clone(),
422                    raw: instances[0][inst.offset..end].to_vec(),
423                });
424            } else {
425                let slot = match slots.iter().position(|s| *s == vector) {
426                    Some(idx) => idx,
427                    None => {
428                        slots.push(vector);
429                        slots.len() - 1
430                    }
431                };
432                items.push(SkelItem::Slot(labels.get(slot).copied()?));
433            }
434        }
435        // Every label must correspond to an actual slot — a mismatch means
436        // the fixtures (or the labels) are wrong.
437        if slots.len() != labels.len() {
438            return None;
439        }
440        Some(Skeleton {
441            name,
442            generation: None,
443            items,
444            param_order: labels.to_vec(),
445        })
446    }
447
448    /// Constructor parameter labels, in order.
449    pub fn params(&self) -> &[&'static str] {
450        &self.param_order
451    }
452
453    /// Re-emit this contract's compiled bytes with new constructor arguments.
454    /// Fixed ops and constant pushes stay byte-identical to the original
455    /// build; each slot is re-encoded from `args` (looked up by label).
456    /// Returns None if an argument is missing. The inverse of `match_script`:
457    /// `match_script(disassemble(emit(args))) == args`.
458    pub fn emit(&self, args: &[(&str, &[u8])]) -> Option<Vec<u8>> {
459        let mut out = Vec::new();
460        for item in &self.items {
461            match item {
462                SkelItem::Op(op) => out.push(*op),
463                SkelItem::ConstPush { raw, .. } => out.extend_from_slice(raw),
464                SkelItem::Slot(label) => {
465                    let (_, value) = args.iter().find(|(l, _)| l == label)?;
466                    out.extend_from_slice(&encode_push(value));
467                }
468            }
469        }
470        Some(out)
471    }
472
473    /// Match a script against this skeleton; on success return its fields in
474    /// constructor order. Repeated slots of the same argument must agree.
475    /// Byte range of each labelled slot inside a program this skeleton
476    /// matches, in first-occurrence order, one entry per USE (a repeated
477    /// argument yields one range per site so a splice updates all of them).
478    ///
479    /// Splicing needs offsets, not instruction indices: an unspent cell never
480    /// reveals its program, so the only way to read its state is to rebuild a
481    /// candidate and check it against the committed hash. Returns `None` when
482    /// the program does not match, so a caller can never splice into bytes
483    /// this skeleton does not actually describe.
484    pub fn slot_ranges(&self, program: &[u8]) -> Option<Vec<(&'static str, Range<usize>)>> {
485        let (instructions, truncated) = disassemble(program);
486        if truncated {
487            return None;
488        }
489        self.match_script(&instructions)?;
490        let mut out = Vec::new();
491        for (item, inst) in self.items.iter().zip(&instructions) {
492            let SkelItem::Slot(label) = item else { continue };
493            let data = inst.data.as_ref()?;
494            // data begins after the opcode and its length prefix
495            let prefix = match inst.opcode {
496                0x01..=0x4b => 1,
497                0x4c => 2,
498                0x4d => 3,
499                0x4e => 5,
500                _ => return None, // small-int pushes carry no spliceable data
501            };
502            let start = inst.offset + prefix;
503            out.push((*label, start..start + data.len()));
504        }
505        Some(out)
506    }
507
508    fn match_script(&self, instructions: &[Instruction]) -> Option<Vec<Field>> {
509        if instructions.len() != self.items.len() {
510            return None;
511        }
512        let mut values: Vec<(&'static str, Vec<u8>)> = Vec::new();
513        for (item, inst) in self.items.iter().zip(instructions) {
514            if !match_skel_item(item, inst, &mut values) {
515                return None;
516            }
517        }
518        Some(fields_in_order(&self.param_order, &values))
519    }
520}
521
522/// Match one skeleton item against one instruction. Slot values accumulate in
523/// `values`; a label seen twice within the same scope must carry the same
524/// value (SilverScript inlines an argument at every use site).
525fn match_skel_item(
526    item: &SkelItem,
527    inst: &Instruction,
528    values: &mut Vec<(&'static str, Vec<u8>)>,
529) -> bool {
530    match item {
531        SkelItem::Op(op) => !is_push(inst) && inst.opcode == *op,
532        SkelItem::ConstPush { value, .. } => push_value(inst).as_ref() == Some(value),
533        SkelItem::Slot(label) => {
534            let Some(v) = push_value(inst) else {
535                return false;
536            };
537            match values.iter().find(|(l, _)| l == label) {
538                Some((_, prev)) => *prev == v,
539                None => {
540                    values.push((label, v));
541                    true
542                }
543            }
544        }
545    }
546}
547
548fn fields_in_order(order: &[&'static str], values: &[(&'static str, Vec<u8>)]) -> Vec<Field> {
549    order
550        .iter()
551        .filter_map(|label| {
552            values.iter().find(|(l, _)| l == label).map(|(_, v)| Field {
553                name: label,
554                value: v.clone(),
555            })
556        })
557        .collect()
558}
559
560/// Push-size-aware shape equality: two instructions align when both are
561/// pushes of the same width or both are the same non-push opcode. This is
562/// the alignment used to find the repeated block of a variable-arity family.
563fn same_shape(a: &Instruction, b: &Instruction) -> bool {
564    match (push_value(a), push_value(b)) {
565        (Some(x), Some(y)) => x.len() == y.len(),
566        (None, None) => a.opcode == b.opcode,
567        _ => false,
568    }
569}
570
571/// A compiled-contract family whose builds differ only by how many times one
572/// instruction block repeats (e.g. genesis0's slot-mint emits one
573/// amount+script check per collection output). Matched as
574/// `prefix · group×N · suffix` with `N >= min_repeats`; the group's pushes
575/// are per-repeat slots, so every arity of the family decodes to one name.
576pub struct RepeatSkeleton {
577    pub name: &'static str,
578    prefix: Vec<SkelItem>,
579    group: Vec<SkelItem>,
580    suffix: Vec<SkelItem>,
581    min_repeats: usize,
582    param_order: Vec<&'static str>,
583    group_params: Vec<&'static str>,
584}
585
586impl RepeatSkeleton {
587    /// Derive from real instances of two different arities: `long` holds two
588    /// or more instances of the bigger build, `short` at least one of the
589    /// smaller. The repeated group is the shape difference between the two
590    /// arities (rotated to its leftmost position, so trailing copies inside
591    /// the longer build's aligned prefix fold into repeats). Fixed-part
592    /// pushes become constants only when *every* instance of *both* arities
593    /// agrees — arity-dependent constants (output counts, indexes) become
594    /// slots automatically. `labels` names the distinct fixed-part slots in
595    /// first-occurrence order; `group_labels` names the group's pushes in
596    /// order (repeat a label to require equality within one repeat).
597    pub fn derive(
598        name: &'static str,
599        long: &[&[u8]],
600        short: &[&[u8]],
601        labels: &[&'static str],
602        group_labels: &[&'static str],
603    ) -> Option<RepeatSkeleton> {
604        if long.len() < 2 || short.is_empty() {
605            return None;
606        }
607        let parse = |set: &[&[u8]]| -> Option<Vec<Vec<Instruction>>> {
608            let mut streams = Vec::with_capacity(set.len());
609            for bytes in set {
610                let (insts, truncated) = disassemble(bytes);
611                if truncated
612                    || streams
613                        .first()
614                        .is_some_and(|f: &Vec<Instruction>| f.len() != insts.len())
615                {
616                    return None;
617                }
618                streams.push(insts);
619            }
620            Some(streams)
621        };
622        let la = parse(long)?;
623        let lb = parse(short)?;
624        let (a0, b0) = (&la[0], &lb[0]);
625        let g = a0.len().checked_sub(b0.len()).filter(|g| *g > 0)?;
626        // Shape-align the two arities from both ends, then rotate the group
627        // window as far left as it goes so it sits at the first repeat.
628        let mut p = 0;
629        while p < b0.len() && same_shape(&a0[p], &b0[p]) {
630            p += 1;
631        }
632        let mut s = 0;
633        while s < b0.len() - p && same_shape(&a0[a0.len() - 1 - s], &b0[b0.len() - 1 - s]) {
634            s += 1;
635        }
636        if a0.len() - p - s < g {
637            return None;
638        }
639        p = a0.len() - s - g; // group directly before the suffix…
640        while p > 0 && same_shape(&a0[p - 1], &a0[p + g - 1]) {
641            p -= 1; // …rotated to its leftmost equivalent position
642        }
643        let extra = b0.len().checked_sub(p + s)?;
644        if extra % g != 0 {
645            return None;
646        }
647        let min_repeats = extra / g;
648
649        // Fixed parts: const/slot decided across every instance of both
650        // arities (suffix positions aligned from the end).
651        let mut slots: Vec<Vec<Vec<u8>>> = Vec::new();
652        let mut build =
653            |positions: &mut dyn Iterator<Item = (usize, usize)>| -> Option<Vec<SkelItem>> {
654                let mut items = Vec::new();
655                for (ia, ib) in positions {
656                    let inst = &a0[ia];
657                    if !is_push(inst) {
658                        let ok = la
659                            .iter()
660                            .all(|x| !is_push(&x[ia]) && x[ia].opcode == inst.opcode)
661                            && lb
662                                .iter()
663                                .all(|x| !is_push(&x[ib]) && x[ib].opcode == inst.opcode);
664                        if !ok {
665                            return None;
666                        }
667                        items.push(SkelItem::Op(inst.opcode));
668                        continue;
669                    }
670                    let vector = la
671                        .iter()
672                        .map(|x| push_value(&x[ia]))
673                        .chain(lb.iter().map(|x| push_value(&x[ib])))
674                        .collect::<Option<Vec<_>>>()?;
675                    if vector.iter().all(|v| *v == vector[0]) {
676                        let end = a0.get(ia + 1).map_or(long[0].len(), |n| n.offset);
677                        items.push(SkelItem::ConstPush {
678                            value: vector[0].clone(),
679                            raw: long[0][inst.offset..end].to_vec(),
680                        });
681                    } else {
682                        let slot = match slots.iter().position(|x| *x == vector) {
683                            Some(idx) => idx,
684                            None => {
685                                slots.push(vector);
686                                slots.len() - 1
687                            }
688                        };
689                        items.push(SkelItem::Slot(labels.get(slot).copied()?));
690                    }
691                }
692                Some(items)
693            };
694        let prefix = build(&mut (0..p).map(|i| (i, i)))?;
695        let suffix = build(&mut (0..s).map(|j| (a0.len() - s + j, b0.len() - s + j)))?;
696        if slots.len() != labels.len() {
697            return None;
698        }
699
700        // The group: every push is a per-repeat slot.
701        let mut group = Vec::with_capacity(g);
702        let mut pushes = 0;
703        for inst in &a0[p..p + g] {
704            if is_push(inst) {
705                group.push(SkelItem::Slot(group_labels.get(pushes)?));
706                pushes += 1;
707            } else {
708                group.push(SkelItem::Op(inst.opcode));
709            }
710        }
711        if pushes != group_labels.len() {
712            return None;
713        }
714        let mut group_params: Vec<&'static str> = Vec::new();
715        for label in group_labels {
716            if !group_params.contains(label) {
717                group_params.push(label);
718            }
719        }
720
721        let skel = RepeatSkeleton {
722            name,
723            prefix,
724            group,
725            suffix,
726            min_repeats,
727            param_order: labels.to_vec(),
728            group_params,
729        };
730        // Nothing is registered on faith: the derived matcher must accept
731        // every instance it was derived from.
732        for bytes in long.iter().chain(short) {
733            let (insts, _) = disassemble(bytes);
734            skel.match_script(&insts)?;
735        }
736        Some(skel)
737    }
738
739    /// Fixed-part parameter labels, in order.
740    pub fn params(&self) -> &[&'static str] {
741        &self.param_order
742    }
743
744    /// Per-repeat parameter labels, in order.
745    pub fn group_params(&self) -> &[&'static str] {
746        &self.group_params
747    }
748
749    /// Match `prefix · group×N · suffix`; fixed-part fields come first (in
750    /// `params()` order), then each repeat's fields in repeat order.
751    fn match_script(&self, instructions: &[Instruction]) -> Option<Vec<Field>> {
752        let fixed = self.prefix.len() + self.suffix.len();
753        let extra = instructions.len().checked_sub(fixed)?;
754        if self.group.is_empty() || extra % self.group.len() != 0 {
755            return None;
756        }
757        let repeats = extra / self.group.len();
758        if repeats < self.min_repeats {
759            return None;
760        }
761        let mut values: Vec<(&'static str, Vec<u8>)> = Vec::new();
762        for (item, inst) in self.prefix.iter().zip(instructions) {
763            if !match_skel_item(item, inst, &mut values) {
764                return None;
765            }
766        }
767        let mut at = self.prefix.len();
768        let mut repeat_fields: Vec<Field> = Vec::new();
769        for _ in 0..repeats {
770            // Fresh scope per repeat: a repeated label must agree within one
771            // repeat (an output index used twice) but may differ across them.
772            let mut rv: Vec<(&'static str, Vec<u8>)> = Vec::new();
773            for item in &self.group {
774                if !match_skel_item(item, &instructions[at], &mut rv) {
775                    return None;
776                }
777                at += 1;
778            }
779            repeat_fields.extend(fields_in_order(&self.group_params, &rv));
780        }
781        for (item, inst) in self.suffix.iter().zip(&instructions[at..]) {
782            if !match_skel_item(item, inst, &mut values) {
783                return None;
784            }
785        }
786        let mut fields = fields_in_order(&self.param_order, &values);
787        fields.append(&mut repeat_fields);
788        Some(fields)
789    }
790}
791
792/// Matches compiled contracts against known skeletons.
793pub struct TemplateDecoder {
794    skeletons: Vec<Skeleton>,
795    repeats: Vec<RepeatSkeleton>,
796}
797
798impl TemplateDecoder {
799    pub fn new(skeletons: Vec<Skeleton>) -> Self {
800        Self {
801            skeletons,
802            repeats: vec![],
803        }
804    }
805
806    pub fn with_repeats(skeletons: Vec<Skeleton>, repeats: Vec<RepeatSkeleton>) -> Self {
807        Self { skeletons, repeats }
808    }
809}
810
811impl StateDecoder for TemplateDecoder {
812    fn name(&self) -> &'static str {
813        "template"
814    }
815    fn decode(&self, _spk_version: u16, script: &[u8]) -> Option<Decoded> {
816        let (instructions, truncated) = disassemble(script);
817        if truncated {
818            return None;
819        }
820        let hit = self
821            .skeletons
822            .iter()
823            .find_map(|s| {
824                s.match_script(&instructions)
825                    .map(|f| (s.name, s.generation, f))
826            })
827            .or_else(|| {
828                self.repeats
829                    .iter()
830                    .find_map(|s| s.match_script(&instructions).map(|f| (s.name, None, f)))
831            });
832        let (name, generation, fields) = hit?;
833        let mut d = base_decode("template", script);
834        d.template = Some(name);
835        d.generation = generation;
836        d.fields = fields;
837        Some(d)
838    }
839}
840
841/* ------------------------------------------------ SilverScript templates
842The example contracts from kaspanet/silverscript
843(silverscript-lang/tests/examples), each compiled twice with sentinel
844constructor arguments; skeletons derive at registration and stay aligned
845with these exact dumps.
846
847Three compiler GENERATIONS emit three different byte shapes for the same
848source, and a skeleton names only the shape it was cut from. Each family
849is therefore registered once per generation, under one name: a coin reads
850as "SilverScript · Mecenas" whichever silverc built it, and `generation`
851says which. The sentinel arguments are the same for every generation
852(dump A, then dump B):
853
854  Mecenas(recipient, funder, pledge, period)  11^32 33^32 100000000 1000
855                                              22^32 44^32 250000000 2000
856  Escrow(arbiter, buyer, seller)              33^32 11^32 22^32
857                                              44^32 22^32 11^32
858  LastWill(inheritor, cold, hot)              33^32 44^32 11^32
859                                              44^32 33^32 22^32
860
861The dumps must reproduce from the named compiler: the same source and
862sentinels through that silverc give these bytes, and nothing else does. */
863
864/// The SilverScript compiler generations kascov can name, as
865/// `(generation id, repository and revision)`. The id is what a
866/// [`Skeleton::generation`], a `Decoded::generation` and the worker's
867/// `/compile` report carry; the revision is the commit the dumps of that
868/// generation were produced with.
869pub const SILVERSCRIPT_GENERATIONS: &[(&str, &str)] = &[
870    (SILVERSCRIPT_GEN_D25BD34, "kaspanet/silverscript d25bd34"),
871    (
872        SILVERSCRIPT_GEN_FORK,
873        "michaelsutton/silverscript d57e5dff62f2a9e0e3dd2d5f203d051911f6276d",
874    ),
875    (
876        SILVERSCRIPT_GEN_V1,
877        "kaspanet/silverscript v1.0.0 3ed973335b59269293564805cc2c58a14595ec03",
878    ),
879];
880/// kaspanet/silverscript at d25bd34: the generation the first dumps came
881/// from. Dispatches on a numeric selector and ends an entrypoint chain
882/// with `67 75 00 69`. No worker ships it; old on-chain reveals match it.
883pub const SILVERSCRIPT_GEN_D25BD34: &str = "d25bd34";
884/// michaelsutton/silverscript at d57e5dff, the rev argent 05ba4b2 pins and
885/// the compiler behind kascov's stdin wrapper (tools/silverc). Numeric
886/// selector, `6a` terminator, 36-byte P2PK locks built by one `OpCat` less.
887pub const SILVERSCRIPT_GEN_FORK: &str = "fork";
888/// kaspanet/silverscript v1.0.0 (3ed9733), the upstream `silverc` CLI.
889/// Dispatches on a four-byte KCC-1 tag (`76 04 <tag> 87 63 75`), checks
890/// `pubkey`/`sig` argument sizes in-script, and hashes with BLAKE3.
891pub const SILVERSCRIPT_GEN_V1: &str = "v1";
892
893const SENT_A32: [u8; 32] = [0x11; 32];
894const SENT_B32: [u8; 32] = [0x22; 32];
895const SENT_C32: [u8; 32] = [0x33; 32];
896const SENT_D32: [u8; 32] = [0x44; 32];
897
898// kaspanet/silverscript d25bd34, through the stdin wrapper.
899const MECENAS_A: &str = "6b6c76009c637502e803b100c3201111111111111111111111111111111111111111111111111111111111111111030000207c7e01ac7e876902e803b9be760400e1f50594527994760400e1f505547993a16300c252795479949c696700c20400e1f5059c6951c3b9bf876951c2789c6968007a75007a75007a75516776519c637578aa2033333333333333333333333333333333333333333333333333333333333333338769765279ac69757551677500696868";
900const MECENAS_B: &str = "6b6c76009c637502d007b100c3202222222222222222222222222222222222222222222222222222222222222222030000207c7e01ac7e876902e803b9be760480b2e60e94527994760480b2e60e547993a16300c252795479949c696700c20480b2e60e9c6951c3b9bf876951c2789c6968007a75007a75007a75516776519c637578aa2044444444444444444444444444444444444444444444444444444444444444448769765279ac69757551677500696868";
901const ESCROW_A: &str = "78aa2033333333333333333333333333333333333333333333333333333333333333338769765279ac6900c2b9be02e803949c6900c3201111111111111111111111111111111111111111111111111111111111111111030000207c7e01ac7e8700c3202222222222222222222222222222222222222222222222222222222222222222030000207c7e01ac7e879b69757551";
902const ESCROW_B: &str = "78aa2044444444444444444444444444444444444444444444444444444444444444448769765279ac6900c2b9be02e803949c6900c3202222222222222222222222222222222222222222222222222222222222222222030000207c7e01ac7e8700c3201111111111111111111111111111111111111111111111111111111111111111030000207c7e01ac7e879b69757551";
903const LASTWILL_A: &str = "6b6c76009c637502b400b178aa2033333333333333333333333333333333333333333333333333333333333333338769765279ac697575516776519c637578aa2044444444444444444444444444444444444444444444444444444444444444448769765279ac697575516776529c637578aa2011111111111111111111111111111111111111111111111111111111111111118769765279ac6900c2b9be02e803949c6900c3b9bf876975755167750069686868";
904const LASTWILL_B: &str = "6b6c76009c637502b400b178aa2044444444444444444444444444444444444444444444444444444444444444448769765279ac697575516776519c637578aa2033333333333333333333333333333333333333333333333333333333333333338769765279ac697575516776529c637578aa2022222222222222222222222222222222222222222222222222222222222222228769765279ac6900c2b9be02e803949c6900c3b9bf876975755167750069686868";
905
906// michaelsutton/silverscript d57e5dff, through the stdin wrapper: the same
907// three sources. The P2PK lock is assembled as `03 000020 20<pk> 7e 01ac 7e`
908// where d25bd34 pushed the key first and swapped, the reclaim tail drops
909// its three alt-stack pops for plain `75 75 75`, and the chain ends `67 6a`.
910const MECENAS_FORK_A: &str = "6b6c76009c637502e803b100c3030000202011111111111111111111111111111111111111111111111111111111111111117e01ac7e876902e803b9be760400e1f50594527994760400e1f505547993a16300c252795479949c696700c20400e1f5059c6951c3b9bf876951c2789c6968757575516776519c637578aa2033333333333333333333333333333333333333333333333333333333333333338769765279ac69757551676a6868";
911const MECENAS_FORK_B: &str = "6b6c76009c637502d007b100c3030000202022222222222222222222222222222222222222222222222222222222222222227e01ac7e876902e803b9be760480b2e60e94527994760480b2e60e547993a16300c252795479949c696700c20480b2e60e9c6951c3b9bf876951c2789c6968757575516776519c637578aa2044444444444444444444444444444444444444444444444444444444444444448769765279ac69757551676a6868";
912const ESCROW_FORK_A: &str = "78aa2033333333333333333333333333333333333333333333333333333333333333338769765279ac6900c2b9be02e803949c6900c3030000202011111111111111111111111111111111111111111111111111111111111111117e01ac7e8700c3030000202022222222222222222222222222222222222222222222222222222222222222227e01ac7e879b69757551";
913const ESCROW_FORK_B: &str = "78aa2044444444444444444444444444444444444444444444444444444444444444448769765279ac6900c2b9be02e803949c6900c3030000202022222222222222222222222222222222222222222222222222222222222222227e01ac7e8700c3030000202011111111111111111111111111111111111111111111111111111111111111117e01ac7e879b69757551";
914const LASTWILL_FORK_A: &str = "6b6c76009c637502b400b178aa2033333333333333333333333333333333333333333333333333333333333333338769765279ac697575516776519c637578aa2044444444444444444444444444444444444444444444444444444444444444448769765279ac697575516776529c637578aa2011111111111111111111111111111111111111111111111111111111111111118769765279ac6900c2b9be02e803949c6900c3b9bf8769757551676a686868";
915const LASTWILL_FORK_B: &str = "6b6c76009c637502b400b178aa2044444444444444444444444444444444444444444444444444444444444444448769765279ac697575516776519c637578aa2033333333333333333333333333333333333333333333333333333333333333338769765279ac697575516776529c637578aa2022222222222222222222222222222222222222222222222222222222222222228769765279ac6900c2b9be02e803949c6900c3b9bf8769757551676a686868";
916
917// kaspanet/silverscript v1.0.0 (3ed9733), the upstream silverc CLI, from
918// tests/examples/covenant_mecenas.sil, covenant_escrow.sil and
919// covenant_last_will.sil at that tag. Every entry opens with
920// `76 04 <blake3(signature)[..4]> 87 63 75` (receive() a9ad9e77,
921// reclaim(pubkey,sig) 3566d492, spend(pubkey,sig) 514bee64, inherit
922// f0719a23, cold 79f22b76, refresh bd741282), a pubkey/sig entry checks
923// its argument sizes (`78 82 0120 9d 75 76 82 0141 9d 75`), and a period
924// is range-checked with OpWithin before OpCheckSequenceVerify. These
925// contracts keep no state, so the artifact's state_span is (0, 0) and the
926// KCC-1 template hash is BLAKE3 over the whole program as the suffix.
927const MECENAS_V1_A: &str = "7604a9ad9e7787637502e8037600050000000001a569b1030000202011111111111111111111111111111111111111111111111111111111111111117e01ac7e00c378876902e803b9be760400e1f50594527994760400e1f505547993a16300c252795479949c696700c20400e1f5059c69b9bf51c378876951c252799c69756875757575516776043566d492876375788201209d75768201419d7578aa2033333333333333333333333333333333333333333333333333333333333333338769765279ac69757551676a6868";
928const MECENAS_V1_B: &str = "7604a9ad9e7787637502d0077600050000000001a569b1030000202022222222222222222222222222222222222222222222222222222222222222227e01ac7e00c378876902e803b9be760480b2e60e94527994760480b2e60e547993a16300c252795479949c696700c20480b2e60e9c69b9bf51c378876951c252799c69756875757575516776043566d492876375788201209d75768201419d7578aa2044444444444444444444444444444444444444444444444444444444444444448769765279ac69757551676a6868";
929const ESCROW_V1_A: &str = "7604514bee64876375788201209d75768201419d7578aa2033333333333333333333333333333333333333333333333333333333333333338769765279ac6902e803b9be789400c2789c69030000202011111111111111111111111111111111111111111111111111111111111111117e01ac7e030000202022222222222222222222222222222222222222222222222222222222222222227e01ac7e00c352798700c352798778789b69757575757575757551676a68";
930const ESCROW_V1_B: &str = "7604514bee64876375788201209d75768201419d7578aa2044444444444444444444444444444444444444444444444444444444444444448769765279ac6902e803b9be789400c2789c69030000202022222222222222222222222222222222222222222222222222222222222222227e01ac7e030000202011111111111111111111111111111111111111111111111111111111111111117e01ac7e00c352798700c352798778789b69757575757575757551676a68";
931const LASTWILL_V1_A: &str = "7604f0719a23876375788201209d75768201419d7502b4007600050000000001a569b178aa2033333333333333333333333333333333333333333333333333333333333333338769765279ac6975755167760479f22b76876375788201209d75768201419d7578aa2044444444444444444444444444444444444444444444444444444444444444448769765279ac69757551677604bd741282876375788201209d75768201419d7578aa2011111111111111111111111111111111111111111111111111111111111111118769765279ac6902e803b9be789400c2789c69b9bf00c3788769757575757551676a686868";
932const LASTWILL_V1_B: &str = "7604f0719a23876375788201209d75768201419d7502b4007600050000000001a569b178aa2044444444444444444444444444444444444444444444444444444444444444448769765279ac6975755167760479f22b76876375788201209d75768201419d7578aa2033333333333333333333333333333333333333333333333333333333333333338769765279ac69757551677604bd741282876375788201209d75768201419d7578aa2022222222222222222222222222222222222222222222222222222222222222228769765279ac6902e803b9be789400c2789c69b9bf00c3788769757575757551676a686868";
933
934/// Every embedded SilverScript example dump as
935/// `(family name, generation, dump A, dump B)`, in registration order. The
936/// web port (web/disasm.js) mirrors this table byte for byte and its test
937/// reads it from here.
938pub const SILVERSCRIPT_DUMPS: &[(&str, &str, &str, &str)] = &[
939    ("SilverScript · Mecenas", SILVERSCRIPT_GEN_D25BD34, MECENAS_A, MECENAS_B),
940    ("SilverScript · Escrow", SILVERSCRIPT_GEN_D25BD34, ESCROW_A, ESCROW_B),
941    ("SilverScript · LastWill", SILVERSCRIPT_GEN_D25BD34, LASTWILL_A, LASTWILL_B),
942    ("SilverScript · Mecenas", SILVERSCRIPT_GEN_FORK, MECENAS_FORK_A, MECENAS_FORK_B),
943    ("SilverScript · Escrow", SILVERSCRIPT_GEN_FORK, ESCROW_FORK_A, ESCROW_FORK_B),
944    ("SilverScript · LastWill", SILVERSCRIPT_GEN_FORK, LASTWILL_FORK_A, LASTWILL_FORK_B),
945    ("SilverScript · Mecenas", SILVERSCRIPT_GEN_V1, MECENAS_V1_A, MECENAS_V1_B),
946    ("SilverScript · Escrow", SILVERSCRIPT_GEN_V1, ESCROW_V1_A, ESCROW_V1_B),
947    ("SilverScript · LastWill", SILVERSCRIPT_GEN_V1, LASTWILL_V1_A, LASTWILL_V1_B),
948];
949
950/// Minimal script-number encoding of the sentinel ints used in the dumps.
951/// Minimal script-number (little-endian, sign-guard) encoding of a
952/// non-negative integer — used for pledge/period args and entrypoint
953/// selectors when emitting a contract or building a spend witness.
954pub fn snum(v: i64) -> Vec<u8> {
955    let mut out = Vec::new();
956    let mut abs = v.unsigned_abs();
957    while abs > 0 {
958        out.push((abs & 0xff) as u8);
959        abs >>= 8;
960    }
961    if let Some(last) = out.last() {
962        if last & 0x80 != 0 {
963            out.push(0);
964        }
965    }
966    out
967}
968
969/// The dump-A sentinel values of one example family, labelled in
970/// constructor order. Shared by every generation of the family.
971pub fn silverscript_sentinels(name: &str) -> Option<Vec<(&'static str, Vec<u8>)>> {
972    Some(match name {
973        // contract Mecenas(pubkey recipient, byte[32] funder, int pledge, int period)
974        "SilverScript · Mecenas" => vec![
975            ("recipient", SENT_A32.to_vec()),
976            ("funder_hash", SENT_C32.to_vec()),
977            ("pledge", snum(100_000_000)),
978            ("period", snum(1000)),
979        ],
980        // contract Escrow(byte[32] arbiter, pubkey buyer, pubkey seller)
981        "SilverScript · Escrow" => vec![
982            ("arbiter_hash", SENT_C32.to_vec()),
983            ("buyer", SENT_A32.to_vec()),
984            ("seller", SENT_B32.to_vec()),
985        ],
986        // contract LastWill(byte[32] inheritor, byte[32] cold, byte[32] hot)
987        "SilverScript · LastWill" => vec![
988            ("inheritor_hash", SENT_C32.to_vec()),
989            ("cold_hash", SENT_D32.to_vec()),
990            ("hot_hash", SENT_A32.to_vec()),
991        ],
992        _ => return None,
993    })
994}
995
996/// One skeleton per (family, generation) in [`SILVERSCRIPT_DUMPS`] order:
997/// the d25bd34 cut of each family first, so a caller that takes the first
998/// skeleton of a name (kascov-labkit's demos) keeps emitting the bytes it
999/// always did.
1000pub fn silverscript_skeletons() -> Vec<Skeleton> {
1001    let hex2 = |s: &str| hex::decode(s).expect("template dump hex");
1002    let mut out = Vec::new();
1003    for (name, generation, a, b) in SILVERSCRIPT_DUMPS {
1004        let Some(sentinels) = silverscript_sentinels(name) else {
1005            continue;
1006        };
1007        if let Some(s) = Skeleton::derive(name, &hex2(a), &hex2(b), &sentinels) {
1008            out.push(s.with_generation(generation));
1009        }
1010    }
1011    out
1012}
1013
1014/// The committed hash of a canonical Kaspa P2SH script-public-key
1015/// (`OpBlake2b OpData32 <hash> OpEqual`), if `spk` has that shape.
1016pub fn p2sh_hash(spk: &[u8]) -> Option<&[u8]> {
1017    (spk.len() == 35 && spk[0] == 0xaa && spk[1] == 0x20 && spk[34] == 0x87).then(|| &spk[2..34])
1018}
1019
1020/* kaspa-x402 escrow v2 (github.com/elldeeone/kaspa-x402): the batch-settlement
1021payment channel. Both dumps compiled from contracts/kaspa-x402-escrow-v2.sil
1022(sha256 0f2090affa8121c4f13cee96d315bb354bdefbe20e2ce94855ec90e86d42c93d) with
1023silverc at the author's pinned commit 6f9e078b, sentinel constructor args.
1024The same compile with a live channel's own constructor values reproduced the
1025program revealed on TN10 byte for byte, so these dumps ARE the chain bytes.
1026settled_total is the mutable state slot (fixed-width i64 LE): it grows with
1027every claim, which is exactly why it must derive as a slot, not a constant. */
1028const X402_ESCROW_A: &str = "6b0877777777777777776c76009c6375527982589d757882589d75b9cbb9cfd2789c697856795579b2519c69b5009c69b3519c69b4529c69b9009c69b9bd080000000000000000876978014001417f0101876978202222222222222222222222222222222222222222222222222222222222222222ac69b9cf76d0519c6976d2519c69b9cb519c69b900cc519c6900d5200000000000000000000000000000000000000000000000000000000000000000876951d5788769587920dd08a0f48c41d89eda672cef33c3d43a4c2bd11083efbcd62dbbae0e018ec30b2033333333333333333333333333333333333333333333333333333333333333337e52797e53797ea8201111111111111111111111111111111111111111111111111111111111111111d769785779557900a269785679a0697600a069765279577994a169b9be78789f6900c200a06900c25279a16900c3a8204444444444444444444444444444444444444444444444444444444444444444876951c2785379949c695779519c690108577953799358cd7eb976c902b20394765193bc7c7eb976c97602a883937cbc7eaa02000001aa7e01207e7c7e01877eb900ccc38875757575757575757575757575516776519c6375b9cbb9cfd2789c69785379b2519c69b5009c69b351a069b400a069b452a169b9009c69b9bd080000000000000000876976014001417f0101876976201111111111111111111111111111111111111111111111111111111111111111ac69b9cf76d0519c6976d2519c69b9cb519c69b900cc009c6900d5788769527900a26900c2b9bea069b4529c6351d5200000000000000000000000000000000000000000000000000000000000000000876951c200a06951c3a82055555555555555555555555555555555555555555555555555555555555555558769685379519c690108537958cd7eb976c902b20394765193bc7c7eb976c97602a883937cbc7eaa02000001aa7e01207e7c7e01877eb900ccc388757575757575516776529c6375b2519c69b3519c69b4519c69b9009c69b9bd0800000000000000008769086666666666666666b078014001417f0101876978201111111111111111111111111111111111111111111111111111111111111111ac69b9cf76d0519c6976d2009c69b9cb009c6900d5200000000000000000000000000000000000000000000000000000000000000000876900c200a06900c2b9bea16900c3a8205555555555555555555555555555555555555555555555555555555555555555876975757551676a686868";
1029const X402_ESCROW_B: &str = "6b080e0e0e0e0e0e0e0e6c76009c6375527982589d757882589d75b9cbb9cfd2789c697856795579b2519c69b5009c69b3519c69b4529c69b9009c69b9bd080000000000000000876978014001417f0101876978209999999999999999999999999999999999999999999999999999999999999999ac69b9cf76d0519c6976d2519c69b9cb519c69b900cc519c6900d5200000000000000000000000000000000000000000000000000000000000000000876951d5788769587920dd08a0f48c41d89eda672cef33c3d43a4c2bd11083efbcd62dbbae0e018ec30b20aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa7e52797e53797ea8208888888888888888888888888888888888888888888888888888888888888888d769785779557900a269785679a0697600a069765279577994a169b9be78789f6900c200a06900c25279a16900c3a820bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb876951c2785379949c695779519c690108577953799358cd7eb976c902b20394765193bc7c7eb976c97602a883937cbc7eaa02000001aa7e01207e7c7e01877eb900ccc38875757575757575757575757575516776519c6375b9cbb9cfd2789c69785379b2519c69b5009c69b351a069b400a069b452a169b9009c69b9bd080000000000000000876976014001417f0101876976208888888888888888888888888888888888888888888888888888888888888888ac69b9cf76d0519c6976d2519c69b9cb519c69b900cc009c6900d5788769527900a26900c2b9bea069b4529c6351d5200000000000000000000000000000000000000000000000000000000000000000876951c200a06951c3a820cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc8769685379519c690108537958cd7eb976c902b20394765193bc7c7eb976c97602a883937cbc7eaa02000001aa7e01207e7c7e01877eb900ccc388757575757575516776529c6375b2519c69b3519c69b4519c69b9009c69b9bd080000000000000000876908ddddddddddddddddb078014001417f0101876978208888888888888888888888888888888888888888888888888888888888888888ac69b9cf76d0519c6976d2009c69b9cb009c6900d5200000000000000000000000000000000000000000000000000000000000000000876900c200a06900c2b9bea16900c3a820cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc876975757551676a686868";
1030
1031/// The kaspa-x402 payment-channel skeletons. Separate from the silverscript
1032/// example contracts: same compiler, different project, and alpha releases
1033/// may re-cut the template — a new generation gets a NEW derive here, never
1034/// an edit of this one, so already-closed channels keep decoding.
1035pub fn x402_skeletons() -> Vec<Skeleton> {
1036    let hex2 = |s: &str| hex::decode(s).expect("template dump hex");
1037    let mut out = Vec::new();
1038    // contract KaspaX402EscrowV2(pubkey client, pubkey server,
1039    //   byte[32] networkHash, byte[32] payoutScriptPublicKeyHash,
1040    //   byte[32] refundScriptPublicKeyHash, byte[8] timeoutLe,
1041    //   int initialSettledTotal /* = the settled_total state slot */)
1042    if let Some(s) = Skeleton::derive(
1043        "x402 · escrow v2",
1044        &hex2(X402_ESCROW_A),
1045        &hex2(X402_ESCROW_B),
1046        &[
1047            ("client", vec![0x11; 32]),
1048            ("server", vec![0x22; 32]),
1049            ("network_hash", vec![0x33; 32]),
1050            ("payout_hash", vec![0x44; 32]),
1051            ("refund_hash", vec![0x55; 32]),
1052            ("timeout_daa", vec![0x66; 8]),
1053            ("settled_total", vec![0x77; 8]),
1054        ],
1055    ) {
1056        out.push(s);
1057    }
1058    out
1059}
1060
1061/// Spend-time reveal: when a P2SH state UTXO is spent, the signature
1062/// script's final push is the program the covenant actually ran. Returns it
1063/// only if its blake2b-256 matches the committed hash.
1064pub fn p2sh_reveal(spk: &[u8], sig_script: &[u8]) -> Option<Vec<u8>> {
1065    let hash = p2sh_hash(spk)?;
1066    let (instructions, truncated) = disassemble(sig_script);
1067    if truncated {
1068        return None;
1069    }
1070    let redeem = instructions.last()?.data.clone()?;
1071    let digest = blake2b_simd::Params::new().hash_length(32).hash(&redeem);
1072    (digest.as_bytes() == hash).then_some(redeem)
1073}
1074
1075/// Try registered decoders in order, ending with the disassembly fallback.
1076pub struct Registry {
1077    decoders: Vec<Box<dyn StateDecoder>>,
1078}
1079
1080impl Default for Registry {
1081    fn default() -> Self {
1082        let mut skeletons = silverscript_skeletons();
1083        skeletons.extend(x402_skeletons());
1084        skeletons.extend(observed::observed_skeletons());
1085        Self {
1086            decoders: vec![
1087                Box::new(TemplateDecoder::with_repeats(
1088                    skeletons,
1089                    observed::observed_repeat_skeletons(),
1090                )),
1091                Box::new(P2pkStateDecoder),
1092                Box::new(P2shCommitmentDecoder),
1093            ],
1094        }
1095    }
1096}
1097
1098impl Registry {
1099    pub fn register(&mut self, decoder: Box<dyn StateDecoder>) {
1100        self.decoders.push(decoder);
1101    }
1102
1103    pub fn decode(&self, spk_version: u16, script: &[u8]) -> Decoded {
1104        self.decoders
1105            .iter()
1106            .find_map(|d| d.decode(spk_version, script))
1107            .or_else(|| DisasmDecoder.decode(spk_version, script))
1108            .expect("disasm fallback always decodes")
1109    }
1110}
1111
1112#[cfg(test)]
1113mod tests {
1114    use super::*;
1115
1116    #[test]
1117    fn p2pk_state_labels_owner() {
1118        let mut script = vec![0x20];
1119        script.extend([0x7f; 32]);
1120        script.push(0xac);
1121        let d = Registry::default().decode(0, &script);
1122        assert_eq!(d.template, Some("p2pk state"));
1123        assert_eq!(d.fields.len(), 1);
1124        assert_eq!(d.fields[0].name, "owner_pubkey");
1125        assert_eq!(d.fields[0].value, vec![0x7f; 32]);
1126    }
1127
1128    #[test]
1129    fn p2sh_commitment_labels_hash() {
1130        let mut script = vec![0xaa, 0x20];
1131        script.extend([0x42; 32]);
1132        script.push(0x87);
1133        let d = Registry::default().decode(0, &script);
1134        assert_eq!(d.template, Some("p2sh commitment"));
1135        assert_eq!(d.fields[0].name, "program_hash");
1136        assert_eq!(d.fields[0].value, vec![0x42; 32]);
1137    }
1138
1139    #[test]
1140    fn all_silverscript_skeletons_derive() {
1141        let got: Vec<_> = silverscript_skeletons()
1142            .iter()
1143            .map(|s| (s.name, s.generation))
1144            .collect();
1145        let want: Vec<_> = SILVERSCRIPT_DUMPS
1146            .iter()
1147            .map(|(n, g, _, _)| (*n, Some(*g)))
1148            .collect();
1149        assert_eq!(
1150            got, want,
1151            "every embedded compiler dump must derive a skeleton, in table order"
1152        );
1153        assert_eq!(got.len(), 9, "three families, three generations each");
1154        for (id, _) in SILVERSCRIPT_GENERATIONS {
1155            assert_eq!(
1156                got.iter().filter(|(_, g)| *g == Some(*id)).count(),
1157                3,
1158                "generation {id} must carry all three families"
1159            );
1160        }
1161    }
1162
1163    /// The same source through a later silverc gives different bytes, and
1164    /// only the matching generation's skeleton names them. Every dump of
1165    /// every generation must decode to its family name with its own
1166    /// generation stamped, and no dump may be named by another
1167    /// generation's cut.
1168    #[test]
1169    fn every_generation_names_its_own_dumps() {
1170        let reg = Registry::default();
1171        for (name, generation, a, b) in SILVERSCRIPT_DUMPS {
1172            for dump in [a, b] {
1173                let d = reg.decode(0, &hex::decode(dump).unwrap());
1174                assert_eq!(d.template, Some(*name), "{name} {generation}");
1175                assert_eq!(d.generation, Some(*generation), "{name} {generation}");
1176            }
1177        }
1178        // the fork's Mecenas B and the 1.0 Mecenas B carry the flipped
1179        // sentinels on the same labels as the d25bd34 cut
1180        for (dump, generation) in [
1181            (MECENAS_FORK_B, SILVERSCRIPT_GEN_FORK),
1182            (MECENAS_V1_B, SILVERSCRIPT_GEN_V1),
1183        ] {
1184            let d = reg.decode(0, &hex::decode(dump).unwrap());
1185            assert_eq!(d.generation, Some(generation));
1186            let get = |n: &str| {
1187                d.fields
1188                    .iter()
1189                    .find(|f| f.name == n)
1190                    .map(|f| f.value.clone())
1191            };
1192            assert_eq!(get("recipient"), Some(vec![0x22; 32]));
1193            assert_eq!(get("funder_hash"), Some(vec![0x44; 32]));
1194            assert_eq!(get("pledge"), Some(snum(250_000_000)));
1195            assert_eq!(get("period"), Some(snum(2000)));
1196        }
1197        // a 1.0 dump's dispatch tags are constants of the skeleton, not
1198        // slots: a forged tag is a different program, not a labelled field
1199        let mut forged = hex::decode(ESCROW_V1_A).unwrap();
1200        forged[2] ^= 0x01; // first byte of the spend(pubkey,sig) tag
1201        let d = reg.decode(0, &forged);
1202        assert_eq!(d.template, None, "a changed dispatch tag must not name");
1203    }
1204
1205    #[test]
1206    fn skeleton_matches_real_compiled_instances_and_labels_args() {
1207        let reg = Registry::default();
1208
1209        // Mecenas instance B: sentinel args flipped vs A
1210        let d = reg.decode(0, &hex::decode(MECENAS_B).unwrap());
1211        assert_eq!(d.template, Some("SilverScript · Mecenas"));
1212        let get = |n: &str| {
1213            d.fields
1214                .iter()
1215                .find(|f| f.name == n)
1216                .map(|f| f.value.clone())
1217        };
1218        assert_eq!(get("recipient"), Some(vec![0x22; 32]));
1219        assert_eq!(get("funder_hash"), Some(vec![0x44; 32]));
1220        assert_eq!(get("pledge"), Some(snum(250_000_000)));
1221        assert_eq!(get("period"), Some(snum(2000)));
1222
1223        // Escrow A: arbiter/buyer/seller land on the right labels even though
1224        // buyer/seller swap between the two builds
1225        let d = reg.decode(0, &hex::decode(ESCROW_A).unwrap());
1226        assert_eq!(d.template, Some("SilverScript · Escrow"));
1227        let get = |n: &str| {
1228            d.fields
1229                .iter()
1230                .find(|f| f.name == n)
1231                .map(|f| f.value.clone())
1232        };
1233        assert_eq!(get("arbiter_hash"), Some(vec![0x33; 32]));
1234        assert_eq!(get("buyer"), Some(vec![0x11; 32]));
1235        assert_eq!(get("seller"), Some(vec![0x22; 32]));
1236
1237        // LastWill B
1238        let d = reg.decode(0, &hex::decode(LASTWILL_B).unwrap());
1239        assert_eq!(d.template, Some("SilverScript · LastWill"));
1240
1241        // one flipped opcode → no template, falls back to plain disasm
1242        let mut broken = hex::decode(MECENAS_A).unwrap();
1243        let last = broken.len() - 1;
1244        broken[last] = 0x51;
1245        let d = reg.decode(0, &broken);
1246        assert_eq!(d.template, None);
1247    }
1248
1249    /// The program channel adf6416e… revealed on TN10, two claims deep
1250    /// (settled_total = 150,000,000): the real chain bytes this skeleton
1251    /// exists to name. Compiling the author's shipped source at their pinned
1252    /// silverc commit with this program's own constructor values reproduces
1253    /// it byte for byte.
1254    const X402_LIVE_TN10: &str = "6b0880d1f008000000006c76009c6375527982589d757882589d75b9cbb9cfd2789c697856795579b2519c69b5009c69b3519c69b4529c69b9009c69b9bd080000000000000000876978014001417f010187697820bdbe5f45ef356d152e45dc56233e9b83a739f66672289fb709f541eef4cedd75ac69b9cf76d0519c6976d2519c69b9cb519c69b900cc519c6900d5200000000000000000000000000000000000000000000000000000000000000000876951d5788769587920dd08a0f48c41d89eda672cef33c3d43a4c2bd11083efbcd62dbbae0e018ec30b203b06c42cd879a4951fe465f255164917ac183fabd3facb8dc33705b7bbd649367e52797e53797ea820e6f0c82b07b9e0786064f95b9a007ee3bd7ef8b69c73d911a290c7547f28d75ad769785779557900a269785679a0697600a069765279577994a169b9be78789f6900c200a06900c25279a16900c3a8201b3e09a7a671767054967df9b649daf212e68f9d1698f51bbfd83c599f50fa20876951c2785379949c695779519c690108577953799358cd7eb976c902b20394765193bc7c7eb976c97602a883937cbc7eaa02000001aa7e01207e7c7e01877eb900ccc38875757575757575757575757575516776519c6375b9cbb9cfd2789c69785379b2519c69b5009c69b351a069b400a069b452a169b9009c69b9bd080000000000000000876976014001417f010187697620e6f0c82b07b9e0786064f95b9a007ee3bd7ef8b69c73d911a290c7547f28d75aac69b9cf76d0519c6976d2519c69b9cb519c69b900cc009c6900d5788769527900a26900c2b9bea069b4529c6351d5200000000000000000000000000000000000000000000000000000000000000000876951c200a06951c3a8200e35c71594d24df8ddcbc23fd8a7627aec1e1806f66d3ee83d7eb1179cb74cb58769685379519c690108537958cd7eb976c902b20394765193bc7c7eb976c97602a883937cbc7eaa02000001aa7e01207e7c7e01877eb900ccc388757575757575516776529c6375b2519c69b3519c69b4519c69b9009c69b9bd08000000000000000087690851842d2000000000b078014001417f010187697820e6f0c82b07b9e0786064f95b9a007ee3bd7ef8b69c73d911a290c7547f28d75aac69b9cf76d0519c6976d2009c69b9cb009c6900d5200000000000000000000000000000000000000000000000000000000000000000876900c200a06900c2b9bea16900c3a8200e35c71594d24df8ddcbc23fd8a7627aec1e1806f66d3ee83d7eb1179cb74cb5876975757551676a686868";
1255
1256    #[test]
1257    fn x402_escrow_skeleton_derives_and_names_the_live_tn10_channel() {
1258        let names: Vec<_> = x402_skeletons().iter().map(|s| s.name).collect();
1259        assert_eq!(names, ["x402 · escrow v2"], "the embedded x402 dumps must derive");
1260
1261        let reg = Registry::default();
1262
1263        // dump B: every constructor slot lands on its label, reported once
1264        // even where the compiler inlines it at several sites
1265        let d = reg.decode(0, &hex::decode(X402_ESCROW_B).unwrap());
1266        assert_eq!(d.template, Some("x402 · escrow v2"));
1267        let get = |n: &str| {
1268            d.fields
1269                .iter()
1270                .find(|f| f.name == n)
1271                .map(|f| f.value.clone())
1272        };
1273        assert_eq!(get("client"), Some(vec![0x88; 32]));
1274        assert_eq!(get("server"), Some(vec![0x99; 32]));
1275        assert_eq!(get("network_hash"), Some(vec![0xaa; 32]));
1276        assert_eq!(get("payout_hash"), Some(vec![0xbb; 32]));
1277        assert_eq!(get("refund_hash"), Some(vec![0xcc; 32]));
1278        assert_eq!(get("timeout_daa"), Some(vec![0xdd; 8]));
1279        assert_eq!(get("settled_total"), Some(vec![0x0e; 8]));
1280        assert_eq!(d.fields.len(), 7, "each repeated slot reports one field");
1281
1282        // the real TN10 reveal: named, with the state decoded
1283        let d = reg.decode(0, &hex::decode(X402_LIVE_TN10).unwrap());
1284        assert_eq!(d.template, Some("x402 · escrow v2"));
1285        let get = |n: &str| {
1286            d.fields
1287                .iter()
1288                .find(|f| f.name == n)
1289                .map(|f| f.value.clone())
1290        };
1291        assert_eq!(
1292            get("settled_total"),
1293            Some(150_000_000i64.to_le_bytes().to_vec()),
1294            "on-chain settled total must equal the channel's two claims"
1295        );
1296        assert_eq!(
1297            get("network_hash").map(hex::encode),
1298            // sha256("kaspa:testnet-10")
1299            Some("3b06c42cd879a4951fe465f255164917ac183fabd3facb8dc33705b7bbd64936".into())
1300        );
1301        assert_eq!(
1302            get("client").map(hex::encode),
1303            Some("e6f0c82b07b9e0786064f95b9a007ee3bd7ef8b69c73d911a290c7547f28d75a".into())
1304        );
1305
1306        // a mismatched repeat of an inlined argument must refuse to match:
1307        // the client key appears at three sites and all three must agree
1308        let mut forged = hex::decode(X402_LIVE_TN10).unwrap();
1309        forged[261] ^= 0x01;
1310        let d = reg.decode(0, &forged);
1311        assert_eq!(d.template, None, "inconsistent inlined client key must not name");
1312    }
1313
1314    #[test]
1315    fn emit_with_sentinel_args_reproduces_each_dump() {
1316        // emitting with the sentinel args must reproduce dump A byte-for-byte,
1317        // for every generation of every family
1318        let skels = silverscript_skeletons();
1319        for (name, generation, dump, _) in SILVERSCRIPT_DUMPS {
1320            let skel = skels
1321                .iter()
1322                .find(|s| s.name == *name && s.generation == Some(*generation))
1323                .expect("skeleton");
1324            let args = silverscript_sentinels(name).expect("sentinels");
1325            let args_ref: Vec<(&str, &[u8])> =
1326                args.iter().map(|(l, v)| (*l, v.as_slice())).collect();
1327            let emitted = skel.emit(&args_ref).expect("emit");
1328            assert_eq!(hex::encode(&emitted), *dump, "{name} {generation} emit != dump");
1329        }
1330    }
1331
1332    #[test]
1333    fn emit_round_trips_fresh_args_including_small_int_selector() {
1334        let reg = Registry::default();
1335        let skels = silverscript_skeletons();
1336        let mecenas = skels
1337            .iter()
1338            .find(|s| s.name == "SilverScript · Mecenas")
1339            .unwrap();
1340        // fresh args, pledge with a sign-guard byte (180 -> b4 00), period a small int (6 -> Op6)
1341        let recipient = vec![0xab; 32];
1342        let funder = vec![0xcd; 32];
1343        let pledge = snum(180);
1344        let period = snum(6);
1345        let args: Vec<(&str, &[u8])> = vec![
1346            ("recipient", &recipient),
1347            ("funder_hash", &funder),
1348            ("pledge", &pledge),
1349            ("period", &period),
1350        ];
1351        let emitted = mecenas.emit(&args).expect("emit");
1352        let d = reg.decode(0, &emitted);
1353        assert_eq!(d.template, Some("SilverScript · Mecenas"));
1354        let get = |n: &str| {
1355            d.fields
1356                .iter()
1357                .find(|f| f.name == n)
1358                .map(|f| f.value.clone())
1359        };
1360        assert_eq!(get("recipient"), Some(recipient));
1361        assert_eq!(get("funder_hash"), Some(funder));
1362        assert_eq!(get("pledge"), Some(snum(180)));
1363        assert_eq!(get("period"), Some(snum(6)));
1364        // period=6 must encode to Op6 (0x56), the canonical small-int push
1365        assert_eq!(encode_push(&snum(6)), vec![0x56]);
1366        // and a 32-byte value uses a direct length-prefixed push
1367        assert_eq!(encode_push(&[0xab; 32])[0], 0x20);
1368    }
1369
1370    #[test]
1371    fn zk_system_classifier() {
1372        // No ZK op → nothing to say.
1373        let mut plain = vec![0x20];
1374        plain.extend([0x00; 32]);
1375        plain.push(0xac);
1376        assert_eq!(zk_system(&plain), None);
1377        assert_eq!(Registry::default().decode(0, &plain).zk_system, None);
1378
1379        // A ~192-byte proof push (Groth16 band) then OpZkPrecompile → groth16.
1380        let mut groth = encode_push(&[0x01; 192]);
1381        groth.push(0xa6);
1382        assert_eq!(zk_system(&groth), Some("groth16"));
1383        assert!(Registry::default().decode(0, &groth).uses_zk_ops);
1384        assert_eq!(
1385            Registry::default().decode(0, &groth).zk_system,
1386            Some("groth16")
1387        );
1388
1389        // A 2 KiB seal push → STARK scale → risc0 (wins over any small push).
1390        let mut risc0 = encode_push(&vec![0xab; 2048]);
1391        risc0.extend(encode_push(&[0xcd; 32])); // image id
1392        risc0.push(0xa6);
1393        assert_eq!(zk_system(&risc0), Some("risc0"));
1394
1395        // ZK op present but only generic 32-byte pushes → too ambiguous.
1396        let mut ambiguous = encode_push(&[0x07; 32]);
1397        ambiguous.push(0xa6);
1398        assert_eq!(zk_system(&ambiguous), None);
1399    }
1400
1401    #[test]
1402    fn observed_skeletons_all_derive() {
1403        let names: Vec<_> = observed::observed_skeletons()
1404            .iter()
1405            .map(|s| s.name)
1406            .collect();
1407        assert_eq!(
1408            names,
1409            [
1410                "PURE",
1411                "genesis0 · list",
1412                "genesis0 · buy",
1413                "genesis0 · list",
1414                "genesis0 · buy",
1415                "genesis0 · collection",
1416                "KCC20 token",
1417                "KCC20 token",
1418                "KCC20 token",
1419                "KCC20 minter",
1420                // KaspaCom genesis0 + KasWare vaults, decoded from TN10
1421                // 2026-08-20. Order is load-bearing at the tail: the
1422                // krc-gate-active skeleton is only nine instructions and would
1423                // shadow richer families, so it must stay registered last.
1424                "genesis0 · root",
1425                "genesis0 · slot-mint",
1426                "genesis0 · slot-mint",
1427                "genesis0 · slot-mint",
1428                "genesis0 · slot-mint",
1429                "genesis0 · buy",
1430                "genesis0 · list",
1431                "genesis0 · style-list",
1432                "genesis0 · dutch-list",
1433                "genesis0 · auction",
1434                "genesis0 · krc-gate-boot",
1435                "KasWare · vault-schnorr",
1436                "KasWare · vault-pqcold",
1437                "KasWare · vault-htlc",
1438                "KasWare · vault-dms",
1439                "KaspaCom · token",
1440                "Zealous · token cell",
1441                "Zealous · token cell",
1442                // ZealousSwap five generations + KaspaRocket, decoded from TN10
1443                // 2026-09-04/05; krc-gate-active still closes the list.
1444                "Zealous · curve",
1445                "Zealous · pool",
1446                "Zealous · position cell",
1447                "Zealous · liquidity position",
1448                "Zealous · curve",
1449                "Zealous · curve",
1450                "Zealous · curve",
1451                "Zealous · curve",
1452                "Zealous · pool",
1453                "Zealous · pool",
1454                "Zealous · pool",
1455                "Zealous · pool",
1456                "Zealous · pool",
1457                "Zealous · pool",
1458                "Zealous · pool",
1459                "Zealous · pool",
1460                "Zealous · pool",
1461                "Zealous · position cell",
1462                "Zealous · position cell",
1463                "Zealous · liquidity position",
1464                "Zealous · liquidity position",
1465                "Zealous · factory",
1466                "Zealous · factory",
1467                "Zealous · factory",
1468                "Zealous · token cell",
1469                "Zealous · token cell",
1470                "Zealous · token cell",
1471                "KaspaRocket · lp-amm",
1472                "KaspaRocket · lp-amm",
1473                "KaspaRocket · lp-amm",
1474                "KaspaRocket · order-escrow",
1475                "KaspaRocket · order-escrow",
1476                "KaspaKaha · pool",
1477                "KaspaKaha · pool",
1478                "KaspaKaha · pool",
1479                "KaspaKaha · pool",
1480                "KaspaKaha · pool",
1481                "KaspaKaha · pool",
1482                "KaspaKaha · pool",
1483                "KaspaKaha · pool",
1484                "KaspaKaha · pool",
1485                "KaspaKaha · pool",
1486                "KaspaCom · token",
1487                "KaspaCom · wrapped token",
1488                "KaspaCom · wrapped token",
1489                "KaspaCom · wrapped token",
1490                "KaspaCom · wrapped token",
1491                "KaspaCom · wrapped token",
1492                "KaspaCom · wrapped token",
1493                "KaspaCom · wrapped token",
1494                "KaspaCom · wrapped token",
1495                "KaspaCom · wrapper",
1496                "KaspaCom · wrapper",
1497                "KaspaCom · wrapper",
1498                "KaspaCom · wrapper",
1499                "genesis0 · mint",
1500                "KRON · curve",
1501                "KRON · curve",
1502                "KRON · curve",
1503                "KRON · curve",
1504                "KRON · pool",
1505                "KRON · pool",
1506                "KRON · pool",
1507                "KRON · pool",
1508                "allowance tn-f",
1509                "log tn-f",
1510                "genesis0 · krc-gate-active",
1511                "KForge · curve",
1512                "KForge · token cell",
1513                "KForge · pool",
1514            ],
1515            "every on-chain fixture pair must derive a skeleton"
1516        );
1517        let repeats: Vec<_> = observed::observed_repeat_skeletons()
1518            .iter()
1519            .map(|s| s.name)
1520            .collect();
1521        assert_eq!(repeats, ["genesis0 · slot-mint"]);
1522    }
1523
1524    #[test]
1525    fn observed_families_match_their_fixture_programs() {
1526        let reg = Registry::default();
1527        let get = |d: &Decoded, n: &str| {
1528            d.fields
1529                .iter()
1530                .find(|f| f.name == n)
1531                .map(|f| f.value.clone())
1532        };
1533
1534        // PURE: the one inlined argument is the CheckSigFromStack key.
1535        let pure = include_bytes!("../fixtures/pure_a.bin");
1536        let d = reg.decode(0, pure);
1537        assert_eq!(d.template, Some("PURE"));
1538        assert_eq!(
1539            get(&d, "signer_pubkey").map(hex::encode).as_deref(),
1540            Some("4df3c68074217004ad86fca1e63b91b73e625d9140063f21992231fdfdfa8936")
1541        );
1542
1543        // KCC20 token: state fields land on the contract's labels. Fixture
1544        // kcc20_b_a is a mint-capable instance owned by a covenant id.
1545        let d = reg.decode(0, include_bytes!("../fixtures/kcc20_b_a.bin"));
1546        assert_eq!(d.template, Some("KCC20 token"));
1547        assert_eq!(get(&d, "owner_identifier").map(|v| v.len()), Some(32));
1548        assert_eq!(get(&d, "identifier_type"), Some(vec![0x02]));
1549        assert_eq!(get(&d, "amount"), Some(vec![0; 8]));
1550        assert_eq!(get(&d, "is_minter"), Some(vec![0x01]));
1551        // …and kcc20_a_a is a plain pubkey-owned, non-minting instance.
1552        let d = reg.decode(0, include_bytes!("../fixtures/kcc20_a_a.bin"));
1553        assert_eq!(d.template, Some("KCC20 token"));
1554        assert_eq!(get(&d, "identifier_type"), Some(vec![0x00]));
1555        assert_eq!(
1556            get(&d, "amount").map(hex::encode).as_deref(),
1557            Some("a00f000000000000")
1558        );
1559        assert_eq!(get(&d, "is_minter"), Some(vec![0x00]));
1560
1561        // KCC20 minter: the input-side and output-side covenant-id pins fold
1562        // into one slot per governed token, so exactly two id fields.
1563        let d = reg.decode(0, include_bytes!("../fixtures/kcc20_minter_a.bin"));
1564        assert_eq!(d.template, Some("KCC20 minter"));
1565        assert_eq!(d.fields.len(), 2);
1566        assert!(d.fields.iter().all(|f| f.value.len() == 32));
1567
1568        // Marketplace stages + collection registry.
1569        for (fixture, want) in [
1570            (
1571                include_bytes!("../fixtures/g0_list_v1_a.bin").as_slice(),
1572                "genesis0 · list",
1573            ),
1574            (
1575                include_bytes!("../fixtures/g0_buy_v1_a.bin").as_slice(),
1576                "genesis0 · buy",
1577            ),
1578            (
1579                include_bytes!("../fixtures/g0_list_v2_b.bin").as_slice(),
1580                "genesis0 · list",
1581            ),
1582            (
1583                include_bytes!("../fixtures/g0_buy_v2_b.bin").as_slice(),
1584                "genesis0 · buy",
1585            ),
1586            (
1587                include_bytes!("../fixtures/g0_col_a.bin").as_slice(),
1588                "genesis0 · collection",
1589            ),
1590        ] {
1591            assert_eq!(
1592                reg.decode(0, fixture).template,
1593                Some(want),
1594                "fixture for {want}"
1595            );
1596        }
1597        // The list program embeds the follow-up buy state's template bytes.
1598        let d = reg.decode(0, include_bytes!("../fixtures/g0_list_v1_a.bin"));
1599        let tmpl = get(&d, "next_state_template").expect("next_state_template");
1600        assert_eq!(tmpl.len(), 396);
1601    }
1602
1603    #[test]
1604    fn slot_mint_repeat_matcher_covers_all_arities() {
1605        let reg = Registry::default();
1606        let get_all = |d: &Decoded, n: &str| {
1607            d.fields
1608                .iter()
1609                .filter(|f| f.name == n)
1610                .map(|f| f.value.clone())
1611                .collect::<Vec<_>>()
1612        };
1613
1614        // DI4M2 build: two per-collection output checks.
1615        let di4m = include_bytes!("../fixtures/slot_mint_di4m_a.bin");
1616        let d = reg.decode(0, di4m);
1617        assert_eq!(d.template, Some("genesis0 · slot-mint"));
1618        assert_eq!(get_all(&d, "lane_tag"), vec![b"DI4M2".to_vec()]);
1619        assert_eq!(get_all(&d, "min_outputs"), vec![vec![0x04]]);
1620        let hashes = get_all(&d, "output_spk_hash");
1621        assert_eq!(hashes.len(), 2, "two repeats in the DI4M2 arity");
1622        assert_eq!(
1623            hex::encode(&hashes[0]),
1624            "c90a93233366793d3a3576e9677a7e1f31ff85c80ba999fc5ca5dbaeac73a544",
1625            "first repeat pins the shared marketplace output"
1626        );
1627
1628        // GZ4M1 build: one check.
1629        let d = reg.decode(0, include_bytes!("../fixtures/slot_mint_gz4m_a.bin"));
1630        assert_eq!(d.template, Some("genesis0 · slot-mint"));
1631        assert_eq!(get_all(&d, "lane_tag"), vec![b"GZ4M1".to_vec()]);
1632        assert_eq!(get_all(&d, "min_outputs"), vec![vec![0x03]]);
1633        assert_eq!(get_all(&d, "output_spk_hash").len(), 1);
1634
1635        // An unseen third arity still matches: splice in another copy of the
1636        // repeated block (instructions 102..111 of the DI4M2 build).
1637        let (insts, _) = disassemble(di4m);
1638        let start = insts[102].offset;
1639        let end = insts[111].offset;
1640        let mut three = di4m[..end].to_vec();
1641        three.extend_from_slice(&di4m[start..end]);
1642        three.extend_from_slice(&di4m[end..]);
1643        let d = reg.decode(0, &three);
1644        assert_eq!(
1645            d.template,
1646            Some("genesis0 · slot-mint"),
1647            "extra repeat must still match"
1648        );
1649        assert_eq!(get_all(&d, "output_spk_hash").len(), 3);
1650
1651        // A partial copy breaks group divisibility → no template.
1652        let mut ragged = di4m[..end].to_vec();
1653        ragged.extend_from_slice(&di4m[start..start + 1]); // lone output-index push
1654        ragged.extend_from_slice(&di4m[end..]);
1655        assert_eq!(reg.decode(0, &ragged).template, None);
1656    }
1657
1658    #[test]
1659    fn derive_observed_edge_cases() {
1660        let a = include_bytes!("../fixtures/pure_a.bin").as_slice();
1661        let b = include_bytes!("../fixtures/pure_b.bin").as_slice();
1662        // one instance is not a derivation
1663        assert!(Skeleton::derive_observed("x", &[a], &["k"]).is_none());
1664        // label count must equal the distinct slots (one here)
1665        assert!(Skeleton::derive_observed("x", &[a, b], &[]).is_none());
1666        assert!(Skeleton::derive_observed("x", &[a, b], &["k", "extra"]).is_none());
1667        assert!(Skeleton::derive_observed("x", &[a, b], &["k"]).is_some());
1668        // shape mismatch across instances → None
1669        let other = include_bytes!("../fixtures/g0_col_a.bin").as_slice();
1670        assert!(Skeleton::derive_observed("x", &[a, other], &["k"]).is_none());
1671        // repeat derivation needs both arities
1672        assert!(RepeatSkeleton::derive("x", &[a, b], &[], &["k"], &[]).is_none());
1673    }
1674
1675    #[test]
1676    fn zk_band_boundaries() {
1677        let probe = |n: usize| {
1678            let mut s = encode_push(&vec![0x5a; n]);
1679            s.push(0xa6); // OpZkPrecompile
1680            zk_system(&s)
1681        };
1682        assert_eq!(probe(127), None);
1683        assert_eq!(probe(128), Some("groth16"));
1684        assert_eq!(probe(256), Some("groth16"));
1685        assert_eq!(
1686            probe(257),
1687            None,
1688            "gap above the Groth16 band stays unattributed"
1689        );
1690        assert_eq!(probe(299), None);
1691        assert_eq!(probe(300), Some("succinct proof (inferred)"));
1692        assert_eq!(probe(1023), Some("succinct proof (inferred)"));
1693        assert_eq!(probe(1024), Some("risc0"));
1694    }
1695
1696    #[test]
1697    fn p2sh_reveal_verifies_and_peels() {
1698        let redeem = vec![0xb9, 0xcf, 0x51]; // OpTxInputIndex OpInputCovenantId OpTrue
1699        let hash = blake2b_simd::Params::new().hash_length(32).hash(&redeem);
1700        let mut spk = vec![0xaa, 0x20];
1701        spk.extend_from_slice(hash.as_bytes());
1702        spk.push(0x87);
1703        // sig script: some witness push, then the redeem script push
1704        let mut sig = vec![0x02, 0x01, 0x02, 0x03];
1705        sig.extend_from_slice(&redeem);
1706        assert_eq!(p2sh_reveal(&spk, &sig), Some(redeem.clone()));
1707
1708        // wrong redeem → hash mismatch → no reveal
1709        let mut bad_sig = vec![0x03];
1710        bad_sig.extend_from_slice(&[0x51, 0x52, 0x53]);
1711        assert_eq!(p2sh_reveal(&spk, &bad_sig), None);
1712
1713        // non-P2SH spk → no reveal
1714        assert_eq!(p2sh_reveal(&[0xac], &sig), None);
1715    }
1716}