Skip to main content

solid_pod_rs/
mrc20.rs

1//! MRC20 token state chain verification and BIP-341 anchor derivation.
2//!
3//! Implements the Blocktrails MRC20 profile (`mono.mrc20.v0.1`):
4//! - RFC 8785 JSON Canonicalization Scheme (JCS) for deterministic hashing.
5//! - SHA-256 hash-chained state transitions with sequence enforcement.
6//! - Transfer operation extraction and deposit verification.
7//!
8//! When the `mrc20` feature is enabled, also provides:
9//! - BIP-341 taproot key chaining for per-state P2TR address derivation.
10//! - Bech32m encoding for taproot addresses.
11//! - Full anchor verification against mempool UTXOs.
12//!
13//! @see <https://blocktrails.org/>
14//! @see JSS `src/mrc20.js`, `src/token.js`
15
16use serde::{Deserialize, Serialize};
17use serde_json::Value;
18use sha2::{Digest, Sha256};
19use std::collections::BTreeMap;
20
21use crate::payments::PaymentError;
22
23pub const MRC20_PROFILE: &str = "mono.mrc20.v0.1";
24pub const TRANSFER_OP: &str = "urn:mono:op:transfer";
25
26// ── RFC 8785 JSON Canonicalization Scheme ────────────────────────────
27
28/// Produce a deterministic JSON string per RFC 8785 (JCS).
29///
30/// Object keys are sorted lexicographically; no whitespace between
31/// tokens; strings and numbers use `JSON.stringify` semantics.
32pub fn jcs(value: &Value) -> String {
33    match value {
34        Value::Null => "null".into(),
35        Value::Bool(b) => if *b { "true" } else { "false" }.into(),
36        Value::Number(n) => {
37            // RFC 8785 §3.2.2.3: integers render without fraction;
38            // floats use shortest representation. serde_json's Display
39            // already follows these rules for i64/u64/f64.
40            n.to_string()
41        }
42        Value::String(_) => {
43            // Delegate escaping to serde_json which handles RFC 8785 §3.2.2.2.
44            serde_json::to_string(value).unwrap_or_else(|_| "\"\"".into())
45        }
46        Value::Array(arr) => {
47            let items: Vec<String> = arr.iter().map(jcs).collect();
48            format!("[{}]", items.join(","))
49        }
50        Value::Object(map) => {
51            let mut keys: Vec<&String> = map.keys().collect();
52            keys.sort();
53            let pairs: Vec<String> = keys
54                .iter()
55                .map(|k| {
56                    let key_json = serde_json::to_string(*k).unwrap_or_default();
57                    format!("{}:{}", key_json, jcs(&map[*k]))
58                })
59                .collect();
60            format!("{{{}}}", pairs.join(","))
61        }
62    }
63}
64
65/// SHA-256 hex digest of a string.
66pub fn sha256_hex(input: &str) -> String {
67    hex::encode(Sha256::digest(input.as_bytes()))
68}
69
70// ── MRC20 state types ───────────────────────────────────────────────
71
72/// Full MRC20 token state in the hash chain.
73///
74/// Each state links to its predecessor via `prev = SHA-256(JCS(prev_state))`.
75/// The genesis state has `prev = "0" * 64` and `seq = 0`.
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct Mrc20State {
78    pub profile: String,
79    pub prev: String,
80    pub seq: u64,
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub ticker: Option<String>,
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub name: Option<String>,
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub decimals: Option<u32>,
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub supply: Option<u64>,
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub balances: Option<BTreeMap<String, u64>>,
91    pub ops: Vec<Mrc20Op>,
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub anchor: Option<String>,
94}
95
96/// A single MRC20 operation within a state transition.
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct Mrc20Op {
99    pub op: String,
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub from: Option<String>,
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub to: Option<String>,
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub amt: Option<u64>,
106}
107
108/// Persistent trail for a token's full state chain history.
109#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct Mrc20Trail {
111    pub ticker: String,
112    pub name: String,
113    pub supply: u64,
114    pub pubkey_base: String,
115    pub states: Vec<Mrc20State>,
116    pub state_strings: Vec<String>,
117    pub current_txid: String,
118    pub current_vout: u32,
119    pub current_amount: u64,
120    pub network: String,
121    pub date_created: String,
122}
123
124/// Result of MRC20 deposit verification.
125#[derive(Debug, Clone)]
126pub struct Mrc20DepositResult {
127    pub amount: u64,
128    pub ticker: String,
129}
130
131// ── State chain verification ────────────────────────────────────────
132
133/// Validate that a state object conforms to MRC20 profile.
134pub fn validate_mrc20_state(state: &Mrc20State) -> Result<(), PaymentError> {
135    if state.profile != MRC20_PROFILE {
136        return Err(PaymentError::InvalidState(format!(
137            "invalid profile: expected {MRC20_PROFILE}, got {}",
138            state.profile
139        )));
140    }
141    if state.prev.is_empty() {
142        return Err(PaymentError::InvalidState("missing prev hash".into()));
143    }
144    Ok(())
145}
146
147/// Verify state chain link: `state.prev` must equal `SHA-256(JCS(prev_state))`.
148///
149/// Uses RFC 8785 JCS for deterministic serialization — **not**
150/// `serde_json::to_string` which does not guarantee key ordering.
151pub fn verify_state_link(state: &Mrc20State, prev_state: &Mrc20State) -> Result<(), PaymentError> {
152    let prev_value = serde_json::to_value(prev_state)
153        .map_err(|e| PaymentError::InvalidState(format!("serialize: {e}")))?;
154    let prev_jcs = jcs(&prev_value);
155    let expected = sha256_hex(&prev_jcs);
156
157    if state.prev != expected {
158        return Err(PaymentError::InvalidState(format!(
159            "chain break: expected prev {expected}, got {}",
160            state.prev
161        )));
162    }
163    if state.seq != prev_state.seq + 1 {
164        return Err(PaymentError::InvalidState(format!(
165            "sequence mismatch: expected {}, got {}",
166            prev_state.seq + 1,
167            state.seq
168        )));
169    }
170    Ok(())
171}
172
173/// Extract transfer operations targeting a specific address.
174pub fn extract_transfers_to<'a>(state: &'a Mrc20State, to_address: &str) -> Vec<&'a Mrc20Op> {
175    state
176        .ops
177        .iter()
178        .filter(|op| {
179            op.op == TRANSFER_OP
180                && op.to.as_deref() == Some(to_address)
181                && op.amt.map_or(false, |a| a > 0)
182        })
183        .collect()
184}
185
186/// Total amount transferred to `to_address` in a given state.
187pub fn total_transferred_to(state: &Mrc20State, to_address: &str) -> u64 {
188    extract_transfers_to(state, to_address)
189        .iter()
190        .filter_map(|op| op.amt)
191        .sum()
192}
193
194/// Verify an MRC20 deposit: validate state chain integrity and extract
195/// the transfer amount to the pod's address.
196pub fn verify_mrc20_deposit(
197    state: &Mrc20State,
198    prev_state: &Mrc20State,
199    to_address: &str,
200) -> Result<Mrc20DepositResult, PaymentError> {
201    validate_mrc20_state(state)?;
202    validate_mrc20_state(prev_state)?;
203    verify_state_link(state, prev_state)?;
204
205    let amount = total_transferred_to(state, to_address);
206    if amount == 0 {
207        return Err(PaymentError::InvalidState(format!(
208            "no transfers to {to_address} in state ops"
209        )));
210    }
211
212    Ok(Mrc20DepositResult {
213        amount,
214        ticker: state.ticker.clone().unwrap_or_else(|| "UNKNOWN".into()),
215    })
216}
217
218// ── Mempool lookup abstraction (pure, wasm-safe) ────────────────────
219//
220// The anchor *crypto* (taproot derivation) lives behind feature `mrc20`,
221// but the lookup boundary is pure: a small trait plus minimal value types
222// that compile for `wasm32-unknown-unknown` with no HTTP, no tokio. The
223// concrete `MempoolHttpClient` (reqwest over the mempool.space REST API)
224// is server-side only (`solid-pod-rs-server::mempool`). This mirrors the
225// crate's existing `?Send` pattern (`PaymentStore`, `provenance::GitMarker`)
226// so a single-threaded wasm executor can implement it.
227
228/// A minimal unspent-output record returned by a [`MempoolLookup`].
229///
230/// Shape mirrors the mempool.space `GET /api/address/{addr}/utxo` element
231/// (JSS `mrc20.js:315-327`, `token.js:176-187`): `txid`, `vout`, `value`
232/// (sats), and the `status.confirmed`/`status.block_height` confirmation
233/// fields flattened to `confirmed` + `block_height`.
234#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
235pub struct Utxo {
236    /// Transaction id funding this output (64-char hex).
237    pub txid: String,
238    /// Output index within `txid`.
239    pub vout: u32,
240    /// Output value in satoshis.
241    pub value: u64,
242    /// Whether the funding tx is confirmed in a block (mempool vs chain).
243    #[serde(default)]
244    pub confirmed: bool,
245    /// Confirmation height; `None` while unconfirmed.
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub block_height: Option<u64>,
248}
249
250/// A single transaction output as returned inside [`TxInfo::vout`].
251#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
252pub struct TxOut {
253    /// Output value in satoshis.
254    #[serde(default)]
255    pub value: u64,
256    /// Hex-encoded `scriptPubKey` of this output.
257    #[serde(default, skip_serializing_if = "Option::is_none")]
258    pub scriptpubkey: Option<String>,
259    /// Decoded address the output pays, when the indexer provides one.
260    #[serde(default, skip_serializing_if = "Option::is_none")]
261    pub scriptpubkey_address: Option<String>,
262}
263
264/// Minimal view of a transaction returned by [`MempoolLookup::tx`].
265///
266/// Mirrors mempool.space `GET /api/tx/{txid}` (JSS `token.js:270-275`):
267/// the `vout` array (for `scriptpubkey` lookup) and the confirmation
268/// `status`.
269#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
270pub struct TxInfo {
271    /// Transaction id (64-char hex).
272    pub txid: String,
273    /// The transaction's outputs, indexed by `vout`.
274    #[serde(default)]
275    pub vout: Vec<TxOut>,
276    /// Whether the tx is confirmed in a block.
277    #[serde(default)]
278    pub confirmed: bool,
279    /// Confirmation height; `None` while unconfirmed.
280    #[serde(default, skip_serializing_if = "Option::is_none")]
281    pub block_height: Option<u64>,
282}
283
284/// Read-only Bitcoin mempool/chain lookup used by anchor verification.
285///
286/// Pure abstraction: implementors fetch UTXO sets and transactions from
287/// whatever transport is appropriate (reqwest on native, `fetch` in a
288/// Worker). The trait itself drags in no I/O, so it — and the value types
289/// above — compile to `wasm32`. `?Send` to match the crate's existing
290/// single-threaded-executor-friendly traits.
291#[async_trait::async_trait(?Send)]
292pub trait MempoolLookup {
293    /// Return every UTXO currently at `address` (confirmed or in-mempool).
294    ///
295    /// An empty vec means "no UTXO" (not an error). A transport failure is
296    /// surfaced as [`PaymentError::InvalidState`] so anchor verification can
297    /// fail-closed without a bespoke error variant.
298    async fn address_utxos(&self, address: &str) -> Result<Vec<Utxo>, PaymentError>;
299
300    /// Fetch a transaction by id (for `scriptPubKey` / confirmation reads).
301    async fn tx(&self, txid: &str) -> Result<TxInfo, PaymentError>;
302}
303
304// ── BIP-341 key chaining (feature: mrc20) ───────────────────────────
305
306#[cfg(feature = "mrc20")]
307mod anchor {
308    use super::*;
309    use k256::elliptic_curve::ff::PrimeField;
310    use k256::ProjectivePoint;
311    use k256::Scalar;
312    use k256::SecretKey;
313
314    fn tagged_hash(tag: &str, msgs: &[&[u8]]) -> [u8; 32] {
315        let tag_hash = Sha256::digest(tag.as_bytes());
316        let mut hasher = Sha256::new();
317        hasher.update(&tag_hash);
318        hasher.update(&tag_hash);
319        for msg in msgs {
320            hasher.update(msg);
321        }
322        hasher.finalize().into()
323    }
324
325    fn bytes_to_scalar(bytes: &[u8; 32]) -> Option<Scalar> {
326        let wide = k256::FieldBytes::from_slice(bytes);
327        Option::from(Scalar::from_repr(*wide))
328    }
329
330    /// Compute the BIP-341 TapTweak scalar for a compressed pubkey and state string.
331    ///
332    /// `scalar = SHA-256_tagged("TapTweak", x_only || SHA-256(state)) mod N`
333    fn bt_scalar(pubkey_compressed: &[u8], state_jcs: &str) -> Option<Scalar> {
334        let x_only = if pubkey_compressed.len() == 33 {
335            &pubkey_compressed[1..]
336        } else if pubkey_compressed.len() == 32 {
337            pubkey_compressed
338        } else {
339            return None;
340        };
341        let state_hash = Sha256::digest(state_jcs.as_bytes());
342        let tweak_bytes = tagged_hash("TapTweak", &[x_only, &state_hash]);
343        bytes_to_scalar(&tweak_bytes)
344    }
345
346    /// Iteratively derive a chained public key through a sequence of state strings.
347    ///
348    /// For each state, tweaks the current point by `G * bt_scalar(current, state)`.
349    pub fn bt_derive_chained_pubkey(
350        pubkey_base_hex: &str,
351        state_strings: &[String],
352    ) -> Result<Vec<u8>, PaymentError> {
353        let pubkey_bytes = hex::decode(pubkey_base_hex)
354            .map_err(|e| PaymentError::InvalidState(format!("bad pubkey hex: {e}")))?;
355
356        let point = k256::PublicKey::from_sec1_bytes(&pubkey_bytes)
357            .map_err(|e| PaymentError::InvalidState(format!("bad pubkey: {e}")))?;
358        let mut p = ProjectivePoint::from(point.as_affine().clone());
359        let mut current_compressed = pubkey_bytes;
360
361        for state_jcs in state_strings {
362            let t = bt_scalar(&current_compressed, state_jcs)
363                .ok_or_else(|| PaymentError::InvalidState("scalar derivation failed".into()))?;
364            p = p + (ProjectivePoint::GENERATOR * t);
365            let affine = p.to_affine();
366            let encoded = k256::PublicKey::from_affine(affine)
367                .map_err(|e| PaymentError::InvalidState(format!("point at infinity: {e}")))?;
368            current_compressed = encoded.to_sec1_bytes().to_vec();
369        }
370
371        Ok(current_compressed)
372    }
373
374    /// Derive a chained private key through a sequence of state strings.
375    pub fn bt_derive_chained_privkey(
376        privkey_hex: &str,
377        state_strings: &[String],
378    ) -> Result<Vec<u8>, PaymentError> {
379        let privkey_bytes = hex::decode(privkey_hex)
380            .map_err(|e| PaymentError::InvalidState(format!("bad privkey hex: {e}")))?;
381
382        let sk = SecretKey::from_slice(&privkey_bytes)
383            .map_err(|e| PaymentError::InvalidState(format!("bad privkey: {e}")))?;
384        let mut d = *sk.to_nonzero_scalar().as_ref();
385
386        let pubkey = sk.public_key();
387        let mut current_compressed = pubkey.to_sec1_bytes().to_vec();
388
389        for state_jcs in state_strings {
390            let t = bt_scalar(&current_compressed, state_jcs)
391                .ok_or_else(|| PaymentError::InvalidState("scalar derivation failed".into()))?;
392            d = d + t;
393            let new_sk = SecretKey::new(d.into());
394            current_compressed = new_sk.public_key().to_sec1_bytes().to_vec();
395        }
396
397        Ok(d.to_bytes().to_vec())
398    }
399
400    // ── Bech32m encoding ────────────────────────────────────────────
401
402    const BECH32_CHARSET: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
403    const BECH32M_CONST: u32 = 0x2bc830a3;
404
405    fn convert_bits(data: &[u8], from: u32, to: u32, pad: bool) -> Vec<u8> {
406        let mut acc: u32 = 0;
407        let mut bits: u32 = 0;
408        let maxv = (1u32 << to) - 1;
409        let mut ret = Vec::new();
410        for &v in data {
411            acc = (acc << from) | (v as u32);
412            bits += from;
413            while bits >= to {
414                bits -= to;
415                ret.push(((acc >> bits) & maxv) as u8);
416            }
417        }
418        if pad && bits > 0 {
419            ret.push(((acc << (to - bits)) & maxv) as u8);
420        }
421        ret
422    }
423
424    fn hrp_expand(hrp: &str) -> Vec<u8> {
425        let mut r: Vec<u8> = hrp.bytes().map(|b| b >> 5).collect();
426        r.push(0);
427        r.extend(hrp.bytes().map(|b| b & 31));
428        r
429    }
430
431    fn polymod(values: &[u8]) -> u32 {
432        const GEN: [u32; 5] = [0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3];
433        let mut chk: u32 = 1;
434        for &v in values {
435            let b = chk >> 25;
436            chk = ((chk & 0x1ffffff) << 5) ^ (v as u32);
437            for i in 0..5 {
438                if (b >> i) & 1 != 0 {
439                    chk ^= GEN[i];
440                }
441            }
442        }
443        chk
444    }
445
446    fn bech32m_encode(hrp: &str, version: u8, program: &[u8]) -> String {
447        let conv = convert_bits(program, 8, 5, true);
448        let mut values = vec![version];
449        values.extend_from_slice(&conv);
450
451        let mut enc = hrp_expand(hrp);
452        enc.extend_from_slice(&values);
453        enc.extend_from_slice(&[0, 0, 0, 0, 0, 0]);
454
455        let plm = polymod(&enc) ^ BECH32M_CONST;
456        let checksum: Vec<u8> = (0..6)
457            .map(|i| ((plm >> (5 * (5 - i))) & 31) as u8)
458            .collect();
459
460        let mut result = String::with_capacity(hrp.len() + 1 + values.len() + 6);
461        result.push_str(hrp);
462        result.push('1');
463        for &v in values.iter().chain(checksum.iter()) {
464            result.push(BECH32_CHARSET[v as usize] as char);
465        }
466        result
467    }
468
469    /// Derive the taproot (P2TR) address for a state chain.
470    ///
471    /// Takes the issuer's compressed pubkey (66-char hex), the full
472    /// sequence of JCS-encoded state strings, and the network.
473    pub fn bt_address(
474        pubkey_hex: &str,
475        state_strings: &[String],
476        network: &str,
477    ) -> Result<String, PaymentError> {
478        let chained = bt_derive_chained_pubkey(pubkey_hex, state_strings)?;
479        let x_only = if chained.len() == 33 {
480            &chained[1..]
481        } else if chained.len() == 32 {
482            &chained[..]
483        } else {
484            return Err(PaymentError::InvalidState("unexpected key length".into()));
485        };
486        let hrp = if network == "mainnet" { "bc" } else { "tb" };
487        Ok(bech32m_encode(hrp, 1, x_only))
488    }
489
490    /// Verify an MRC20 deposit is anchored to a confirmed/in-mempool Bitcoin UTXO.
491    ///
492    /// This is the full, independently-verifiable anchor check (JSS
493    /// `mrc20.js:279-335`): it composes
494    ///
495    /// 1. state-chain integrity + transfer extraction ([`verify_mrc20_deposit`]),
496    /// 2. `stateStrings`/pubkey shape validation and the last-string↔`JCS(state)` bind,
497    /// 3. taproot address re-derivation ([`bt_address`]) from the *portable*
498    ///    proof (`pubkey` + `state_strings`), then
499    /// 4. a **mempool UTXO lookup** at that derived address via the supplied
500    ///    [`MempoolLookup`].
501    ///
502    /// The earlier shape, which returned the derived address and left the
503    /// UTXO lookup "to the caller", is replaced: the lookup is now part of
504    /// verification, so a verified result genuinely means *anchored on
505    /// Bitcoin*. Pass a server-side `MempoolHttpClient` on native, or a
506    /// fixture implementation in tests — the crypto and the chain read
507    /// compose in one place.
508    pub async fn verify_mrc20_anchor(
509        state: &Mrc20State,
510        prev_state: &Mrc20State,
511        to_address: &str,
512        pubkey_hex: &str,
513        state_strings: &[String],
514        network: &str,
515        mempool: &dyn MempoolLookup,
516    ) -> Result<Mrc20AnchorResult, PaymentError> {
517        let deposit = verify_mrc20_deposit(state, prev_state, to_address)?;
518
519        if state_strings.is_empty() {
520            return Err(PaymentError::InvalidState(
521                "stateStrings required for anchor verification".into(),
522            ));
523        }
524        if pubkey_hex.len() != 66 {
525            return Err(PaymentError::InvalidState(
526                "pubkey must be a 66-char compressed pubkey hex".into(),
527            ));
528        }
529
530        // Verify last state string matches current state JCS
531        let current_value = serde_json::to_value(state)
532            .map_err(|e| PaymentError::InvalidState(format!("serialize: {e}")))?;
533        let current_jcs = jcs(&current_value);
534        if let Some(last) = state_strings.last() {
535            if *last != current_jcs {
536                return Err(PaymentError::InvalidState(
537                    "last stateString does not match JCS(state)".into(),
538                ));
539            }
540        }
541
542        let address = bt_address(pubkey_hex, state_strings, network)?;
543
544        // Mempool lookup: the anchor is only valid if a UTXO actually
545        // exists at the derived taproot address (JSS `mrc20.js:315-327`).
546        let utxos = mempool.address_utxos(&address).await?;
547        if utxos.is_empty() {
548            return Err(PaymentError::InvalidState(format!(
549                "no UTXO at derived address {address}"
550            )));
551        }
552
553        Ok(Mrc20AnchorResult {
554            amount: deposit.amount,
555            ticker: deposit.ticker,
556            address,
557            utxos,
558        })
559    }
560
561    /// Result of anchor verification.
562    ///
563    /// Carries the verified transfer amount/ticker, the derived taproot
564    /// `address`, and the `utxos` found there (so callers can inspect
565    /// confirmation depth without a second round-trip).
566    #[derive(Debug, Clone)]
567    pub struct Mrc20AnchorResult {
568        pub amount: u64,
569        pub ticker: String,
570        pub address: String,
571        pub utxos: Vec<Utxo>,
572    }
573}
574
575#[cfg(feature = "mrc20")]
576pub use anchor::*;
577
578// ── Tests ───────────────────────────────────────────────────────────
579
580#[cfg(test)]
581mod tests {
582    use super::*;
583    use serde_json::json;
584
585    // ── JCS ──────────────────────────────────────────────────────────
586
587    #[test]
588    fn jcs_sorts_keys() {
589        let val = json!({"z": 1, "a": 2, "m": 3});
590        assert_eq!(jcs(&val), r#"{"a":2,"m":3,"z":1}"#);
591    }
592
593    #[test]
594    fn jcs_nested_objects() {
595        let val = json!({"b": {"d": 1, "c": 2}, "a": 3});
596        assert_eq!(jcs(&val), r#"{"a":3,"b":{"c":2,"d":1}}"#);
597    }
598
599    #[test]
600    fn jcs_array() {
601        let val = json!([3, 1, 2]);
602        assert_eq!(jcs(&val), "[3,1,2]");
603    }
604
605    #[test]
606    fn jcs_string_escaping() {
607        let val = json!({"key": "hello \"world\""});
608        assert_eq!(jcs(&val), r#"{"key":"hello \"world\""}"#);
609    }
610
611    #[test]
612    fn jcs_null_bool() {
613        assert_eq!(jcs(&json!(null)), "null");
614        assert_eq!(jcs(&json!(true)), "true");
615        assert_eq!(jcs(&json!(false)), "false");
616    }
617
618    #[test]
619    fn jcs_empty_object_and_array() {
620        assert_eq!(jcs(&json!({})), "{}");
621        assert_eq!(jcs(&json!([])), "[]");
622    }
623
624    #[test]
625    fn jcs_deterministic() {
626        let val = json!({"profile": "mono.mrc20.v0.1", "prev": "abc", "seq": 0, "ops": []});
627        let a = jcs(&val);
628        let b = jcs(&val);
629        assert_eq!(a, b);
630    }
631
632    // ── SHA-256 ──────────────────────────────────────────────────────
633
634    #[test]
635    fn sha256_hex_known_vector() {
636        assert_eq!(
637            sha256_hex(""),
638            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
639        );
640    }
641
642    #[test]
643    fn sha256_hex_deterministic() {
644        let a = sha256_hex("hello");
645        let b = sha256_hex("hello");
646        assert_eq!(a, b);
647    }
648
649    // ── State validation ─────────────────────────────────────────────
650
651    fn genesis_state() -> Mrc20State {
652        Mrc20State {
653            profile: MRC20_PROFILE.into(),
654            prev: "0".repeat(64),
655            seq: 0,
656            ticker: Some("TEST".into()),
657            name: Some("Test Token".into()),
658            decimals: Some(0),
659            supply: Some(1000),
660            balances: Some(BTreeMap::from([("issuer".into(), 1000)])),
661            ops: vec![],
662            anchor: None,
663        }
664    }
665
666    fn transfer_state(prev_jcs_hash: &str) -> Mrc20State {
667        Mrc20State {
668            profile: MRC20_PROFILE.into(),
669            prev: prev_jcs_hash.into(),
670            seq: 1,
671            ticker: Some("TEST".into()),
672            name: Some("Test Token".into()),
673            decimals: Some(0),
674            supply: Some(1000),
675            balances: Some(BTreeMap::from([
676                ("issuer".into(), 900),
677                ("recipient".into(), 100),
678            ])),
679            ops: vec![Mrc20Op {
680                op: TRANSFER_OP.into(),
681                from: Some("issuer".into()),
682                to: Some("recipient".into()),
683                amt: Some(100),
684            }],
685            anchor: None,
686        }
687    }
688
689    #[test]
690    fn validate_valid_state() {
691        assert!(validate_mrc20_state(&genesis_state()).is_ok());
692    }
693
694    #[test]
695    fn validate_rejects_wrong_profile() {
696        let mut state = genesis_state();
697        state.profile = "wrong".into();
698        assert!(validate_mrc20_state(&state).is_err());
699    }
700
701    #[test]
702    fn validate_rejects_empty_prev() {
703        let mut state = genesis_state();
704        state.prev = String::new();
705        assert!(validate_mrc20_state(&state).is_err());
706    }
707
708    // ── State chain linking ──────────────────────────────────────────
709
710    #[test]
711    fn verify_state_link_valid_chain() {
712        let genesis = genesis_state();
713        let genesis_value = serde_json::to_value(&genesis).unwrap();
714        let genesis_hash = sha256_hex(&jcs(&genesis_value));
715        let next = transfer_state(&genesis_hash);
716
717        assert!(verify_state_link(&next, &genesis).is_ok());
718    }
719
720    #[test]
721    fn verify_state_link_detects_break() {
722        let genesis = genesis_state();
723        let next = transfer_state("wrong_hash");
724
725        assert!(verify_state_link(&next, &genesis).is_err());
726    }
727
728    #[test]
729    fn verify_state_link_detects_sequence_gap() {
730        let genesis = genesis_state();
731        let genesis_value = serde_json::to_value(&genesis).unwrap();
732        let genesis_hash = sha256_hex(&jcs(&genesis_value));
733        let mut next = transfer_state(&genesis_hash);
734        next.seq = 5;
735
736        let err = verify_state_link(&next, &genesis);
737        assert!(err.is_err());
738    }
739
740    // ── Transfer extraction ──────────────────────────────────────────
741
742    #[test]
743    fn extract_transfers_finds_matching() {
744        let genesis = genesis_state();
745        let genesis_value = serde_json::to_value(&genesis).unwrap();
746        let genesis_hash = sha256_hex(&jcs(&genesis_value));
747        let state = transfer_state(&genesis_hash);
748
749        let transfers = extract_transfers_to(&state, "recipient");
750        assert_eq!(transfers.len(), 1);
751        assert_eq!(transfers[0].amt, Some(100));
752    }
753
754    #[test]
755    fn extract_transfers_ignores_wrong_recipient() {
756        let genesis = genesis_state();
757        let genesis_value = serde_json::to_value(&genesis).unwrap();
758        let genesis_hash = sha256_hex(&jcs(&genesis_value));
759        let state = transfer_state(&genesis_hash);
760
761        let transfers = extract_transfers_to(&state, "nobody");
762        assert!(transfers.is_empty());
763    }
764
765    #[test]
766    fn extract_transfers_ignores_zero_amount() {
767        let state = Mrc20State {
768            profile: MRC20_PROFILE.into(),
769            prev: "0".repeat(64),
770            seq: 0,
771            ticker: None,
772            name: None,
773            decimals: None,
774            supply: None,
775            balances: None,
776            ops: vec![Mrc20Op {
777                op: TRANSFER_OP.into(),
778                from: Some("a".into()),
779                to: Some("b".into()),
780                amt: Some(0),
781            }],
782            anchor: None,
783        };
784        assert!(extract_transfers_to(&state, "b").is_empty());
785    }
786
787    #[test]
788    fn total_transferred_sums_multiple_ops() {
789        let state = Mrc20State {
790            profile: MRC20_PROFILE.into(),
791            prev: "0".repeat(64),
792            seq: 0,
793            ticker: None,
794            name: None,
795            decimals: None,
796            supply: None,
797            balances: None,
798            ops: vec![
799                Mrc20Op {
800                    op: TRANSFER_OP.into(),
801                    from: Some("a".into()),
802                    to: Some("pod".into()),
803                    amt: Some(50),
804                },
805                Mrc20Op {
806                    op: TRANSFER_OP.into(),
807                    from: Some("b".into()),
808                    to: Some("pod".into()),
809                    amt: Some(30),
810                },
811                Mrc20Op {
812                    op: TRANSFER_OP.into(),
813                    from: Some("c".into()),
814                    to: Some("other".into()),
815                    amt: Some(20),
816                },
817            ],
818            anchor: None,
819        };
820        assert_eq!(total_transferred_to(&state, "pod"), 80);
821    }
822
823    // ── Full deposit verification ────────────────────────────────────
824
825    #[test]
826    fn verify_deposit_success() {
827        let genesis = genesis_state();
828        let genesis_value = serde_json::to_value(&genesis).unwrap();
829        let genesis_hash = sha256_hex(&jcs(&genesis_value));
830        let next = transfer_state(&genesis_hash);
831
832        let result = verify_mrc20_deposit(&next, &genesis, "recipient").unwrap();
833        assert_eq!(result.amount, 100);
834        assert_eq!(result.ticker, "TEST");
835    }
836
837    #[test]
838    fn verify_deposit_fails_no_transfers() {
839        let genesis = genesis_state();
840        let genesis_value = serde_json::to_value(&genesis).unwrap();
841        let genesis_hash = sha256_hex(&jcs(&genesis_value));
842        let next = transfer_state(&genesis_hash);
843
844        let err = verify_mrc20_deposit(&next, &genesis, "nobody");
845        assert!(err.is_err());
846    }
847
848    #[test]
849    fn verify_deposit_fails_chain_break() {
850        let genesis = genesis_state();
851        let next = transfer_state("bad_hash");
852
853        let err = verify_mrc20_deposit(&next, &genesis, "recipient");
854        assert!(err.is_err());
855    }
856
857    // ── Trail serialization ──────────────────────────────────────────
858
859    #[test]
860    fn trail_roundtrip() {
861        let trail = Mrc20Trail {
862            ticker: "TEST".into(),
863            name: "Test".into(),
864            supply: 1000,
865            pubkey_base: "02".to_string() + &"ab".repeat(32),
866            states: vec![genesis_state()],
867            state_strings: vec![jcs(&serde_json::to_value(&genesis_state()).unwrap())],
868            current_txid: "a".repeat(64),
869            current_vout: 0,
870            current_amount: 9700,
871            network: "testnet4".into(),
872            date_created: "2026-05-11T00:00:00Z".into(),
873        };
874        let json = serde_json::to_string(&trail).unwrap();
875        let parsed: Mrc20Trail = serde_json::from_str(&json).unwrap();
876        assert_eq!(parsed.ticker, "TEST");
877        assert_eq!(parsed.supply, 1000);
878        assert_eq!(parsed.states.len(), 1);
879    }
880
881    // ── JCS ↔ serde_json interop ─────────────────────────────────────
882
883    #[test]
884    fn jcs_state_is_deterministic_across_serializations() {
885        let state = genesis_state();
886        let v1 = serde_json::to_value(&state).unwrap();
887        let v2 = serde_json::to_value(&state).unwrap();
888        assert_eq!(jcs(&v1), jcs(&v2));
889    }
890
891    // ── BIP-341 anchor tests (feature: mrc20) ───────────────────────
892
893    #[cfg(feature = "mrc20")]
894    mod anchor_tests {
895        use super::*;
896        use std::collections::HashMap;
897
898        // k256 test keypair (arbitrary).
899        const TEST_PRIVKEY: &str =
900            "0000000000000000000000000000000000000000000000000000000000000001";
901
902        fn test_pubkey_compressed() -> String {
903            let sk = k256::SecretKey::from_slice(&hex::decode(TEST_PRIVKEY).unwrap()).unwrap();
904            hex::encode(sk.public_key().to_sec1_bytes())
905        }
906
907        /// Fixture [`MempoolLookup`] backed by an in-memory address→UTXO map
908        /// — NO network. Lets the anchor crypto + lookup be exercised
909        /// deterministically (any UTXO present ⇒ anchored; absent ⇒ not).
910        struct FixtureMempool {
911            utxos: HashMap<String, Vec<Utxo>>,
912        }
913        impl FixtureMempool {
914            fn empty() -> Self {
915                Self { utxos: HashMap::new() }
916            }
917            /// Register one UTXO at `address` (value/vout arbitrary but plausible).
918            fn with_utxo_at(address: &str) -> Self {
919                let mut utxos = HashMap::new();
920                utxos.insert(
921                    address.to_string(),
922                    vec![Utxo {
923                        txid: "ab".repeat(32),
924                        vout: 0,
925                        value: 9700,
926                        confirmed: true,
927                        block_height: Some(840_000),
928                    }],
929                );
930                Self { utxos }
931            }
932        }
933        #[async_trait::async_trait(?Send)]
934        impl MempoolLookup for FixtureMempool {
935            async fn address_utxos(&self, address: &str) -> Result<Vec<Utxo>, PaymentError> {
936                Ok(self.utxos.get(address).cloned().unwrap_or_default())
937            }
938            async fn tx(&self, txid: &str) -> Result<TxInfo, PaymentError> {
939                Ok(TxInfo {
940                    txid: txid.to_string(),
941                    vout: vec![],
942                    confirmed: true,
943                    block_height: Some(840_000),
944                })
945            }
946        }
947
948        /// Drive an async future to completion on a single-thread runtime —
949        /// the verify path is now async; this keeps the unit tests `#[test]`.
950        fn block_on<F: std::future::Future>(f: F) -> F::Output {
951            tokio::runtime::Builder::new_current_thread()
952                .build()
953                .unwrap()
954                .block_on(f)
955        }
956
957        /// Build a valid genesis→transfer chain plus its `state_strings`,
958        /// returning `(prev, next, state_strings)` ready for anchor verify.
959        fn valid_chain() -> (Mrc20State, Mrc20State, Vec<String>) {
960            let genesis = genesis_state();
961            let genesis_val = serde_json::to_value(&genesis).unwrap();
962            let genesis_jcs = jcs(&genesis_val);
963            let genesis_hash = sha256_hex(&genesis_jcs);
964            let next = transfer_state(&genesis_hash);
965            let next_jcs = jcs(&serde_json::to_value(&next).unwrap());
966            (genesis, next, vec![genesis_jcs, next_jcs])
967        }
968
969        #[test]
970        fn bt_derive_chained_pubkey_single_state() {
971            let pubkey = test_pubkey_compressed();
972            let states = vec!["genesis_state".into()];
973            let result = bt_derive_chained_pubkey(&pubkey, &states);
974            assert!(result.is_ok());
975            let chained = result.unwrap();
976            assert_eq!(chained.len(), 33);
977            assert!(chained[0] == 0x02 || chained[0] == 0x03);
978        }
979
980        #[test]
981        fn bt_derive_chained_pubkey_multiple_states() {
982            let pubkey = test_pubkey_compressed();
983            let states = vec!["state0".into(), "state1".into(), "state2".into()];
984            let result = bt_derive_chained_pubkey(&pubkey, &states);
985            assert!(result.is_ok());
986        }
987
988        #[test]
989        fn bt_derive_chained_pubkey_deterministic() {
990            let pubkey = test_pubkey_compressed();
991            let states = vec!["s1".into(), "s2".into()];
992            let a = bt_derive_chained_pubkey(&pubkey, &states).unwrap();
993            let b = bt_derive_chained_pubkey(&pubkey, &states).unwrap();
994            assert_eq!(a, b);
995        }
996
997        #[test]
998        fn bt_derive_chained_pubkey_differs_per_state() {
999            let pubkey = test_pubkey_compressed();
1000            let a = bt_derive_chained_pubkey(&pubkey, &["s1".into()]).unwrap();
1001            let b = bt_derive_chained_pubkey(&pubkey, &["s2".into()]).unwrap();
1002            assert_ne!(a, b);
1003        }
1004
1005        #[test]
1006        fn bt_derive_chained_privkey_roundtrip() {
1007            let pubkey = test_pubkey_compressed();
1008            let states = vec!["state1".into()];
1009
1010            let chained_priv = bt_derive_chained_privkey(TEST_PRIVKEY, &states).unwrap();
1011            let chained_sk = k256::SecretKey::from_slice(&chained_priv).unwrap();
1012            let chained_pub_from_priv = hex::encode(chained_sk.public_key().to_sec1_bytes());
1013
1014            let chained_pub = hex::encode(bt_derive_chained_pubkey(&pubkey, &states).unwrap());
1015            assert_eq!(chained_pub_from_priv, chained_pub);
1016        }
1017
1018        #[test]
1019        fn bt_address_testnet_format() {
1020            let pubkey = test_pubkey_compressed();
1021            let states = vec!["genesis".into()];
1022            let addr = bt_address(&pubkey, &states, "testnet4").unwrap();
1023            assert!(
1024                addr.starts_with("tb1p"),
1025                "expected tb1p prefix, got: {addr}"
1026            );
1027        }
1028
1029        #[test]
1030        fn bt_address_mainnet_format() {
1031            let pubkey = test_pubkey_compressed();
1032            let states = vec!["genesis".into()];
1033            let addr = bt_address(&pubkey, &states, "mainnet").unwrap();
1034            assert!(
1035                addr.starts_with("bc1p"),
1036                "expected bc1p prefix, got: {addr}"
1037            );
1038        }
1039
1040        #[test]
1041        fn bt_address_deterministic() {
1042            let pubkey = test_pubkey_compressed();
1043            let states = vec!["s1".into(), "s2".into()];
1044            let a = bt_address(&pubkey, &states, "testnet4").unwrap();
1045            let b = bt_address(&pubkey, &states, "testnet4").unwrap();
1046            assert_eq!(a, b);
1047        }
1048
1049        #[test]
1050        fn bt_address_differs_per_network() {
1051            let pubkey = test_pubkey_compressed();
1052            let states = vec!["s1".into()];
1053            let testnet = bt_address(&pubkey, &states, "testnet4").unwrap();
1054            let mainnet = bt_address(&pubkey, &states, "mainnet").unwrap();
1055            assert_ne!(testnet, mainnet);
1056            assert!(testnet.starts_with("tb1p"));
1057            assert!(mainnet.starts_with("bc1p"));
1058        }
1059
1060        #[test]
1061        fn verify_anchor_rejects_empty_state_strings() {
1062            let genesis = genesis_state();
1063            let genesis_val = serde_json::to_value(&genesis).unwrap();
1064            let genesis_hash = sha256_hex(&jcs(&genesis_val));
1065            let next = transfer_state(&genesis_hash);
1066            let pubkey = test_pubkey_compressed();
1067            let mp = FixtureMempool::empty();
1068
1069            let result = block_on(verify_mrc20_anchor(
1070                &next, &genesis, "recipient", &pubkey, &[], "testnet4", &mp,
1071            ));
1072            assert!(result.is_err());
1073        }
1074
1075        #[test]
1076        fn verify_anchor_rejects_bad_pubkey_length() {
1077            let genesis = genesis_state();
1078            let genesis_val = serde_json::to_value(&genesis).unwrap();
1079            let genesis_hash = sha256_hex(&jcs(&genesis_val));
1080            let next = transfer_state(&genesis_hash);
1081            let mp = FixtureMempool::empty();
1082
1083            let result = block_on(verify_mrc20_anchor(
1084                &next,
1085                &genesis,
1086                "recipient",
1087                "short",
1088                &["s1".into()],
1089                "testnet4",
1090                &mp,
1091            ));
1092            assert!(result.is_err());
1093        }
1094
1095        // ── Mempool-composed verification (fixture, no live chain) ──────
1096
1097        /// TRUE path: a UTXO exists at the derived taproot address ⇒ the
1098        /// anchor verifies and reports the transfer amount.
1099        #[test]
1100        fn verify_anchor_true_when_utxo_present() {
1101            let (genesis, next, state_strings) = valid_chain();
1102            let pubkey = test_pubkey_compressed();
1103            // Derive the SAME address the verifier will, and seed it.
1104            let address = bt_address(&pubkey, &state_strings, "testnet4").unwrap();
1105            let mp = FixtureMempool::with_utxo_at(&address);
1106
1107            let result = block_on(verify_mrc20_anchor(
1108                &next,
1109                &genesis,
1110                "recipient",
1111                &pubkey,
1112                &state_strings,
1113                "testnet4",
1114                &mp,
1115            ))
1116            .expect("anchor with a present UTXO must verify");
1117            assert_eq!(result.amount, 100);
1118            assert_eq!(result.ticker, "TEST");
1119            assert_eq!(result.address, address);
1120            assert_eq!(result.utxos.len(), 1);
1121            assert!(result.utxos[0].confirmed);
1122        }
1123
1124        /// FALSE path: crypto is valid but NO UTXO sits at the derived
1125        /// address ⇒ verification fails closed.
1126        #[test]
1127        fn verify_anchor_false_when_utxo_absent() {
1128            let (genesis, next, state_strings) = valid_chain();
1129            let pubkey = test_pubkey_compressed();
1130            let mp = FixtureMempool::empty(); // nothing anywhere
1131
1132            let err = block_on(verify_mrc20_anchor(
1133                &next,
1134                &genesis,
1135                "recipient",
1136                &pubkey,
1137                &state_strings,
1138                "testnet4",
1139                &mp,
1140            ))
1141            .unwrap_err();
1142            match err {
1143                PaymentError::InvalidState(m) => assert!(m.contains("no UTXO")),
1144                other => panic!("expected no-UTXO InvalidState, got {other:?}"),
1145            }
1146        }
1147
1148        /// MISMATCH path: a UTXO exists, but at a DIFFERENT address (e.g. a
1149        /// UTXO for some other state chain) ⇒ verification still fails,
1150        /// because the lookup is keyed on the *derived* address.
1151        #[test]
1152        fn verify_anchor_false_when_utxo_at_wrong_address() {
1153            let (genesis, next, state_strings) = valid_chain();
1154            let pubkey = test_pubkey_compressed();
1155            let mp = FixtureMempool::with_utxo_at("tb1pdecoy0000000000000000000000000000");
1156
1157            let result = block_on(verify_mrc20_anchor(
1158                &next,
1159                &genesis,
1160                "recipient",
1161                &pubkey,
1162                &state_strings,
1163                "testnet4",
1164                &mp,
1165            ));
1166            assert!(result.is_err(), "UTXO at a non-derived address must not verify");
1167        }
1168    }
1169}