Skip to main content

urma_runtime/
litecoin.rs

1use crate::commitment::PreparedReveal;
2use crate::config::Limits;
3use crate::config::{
4    LITECOIN_MAX_FEE_LITOSHIS, LITECOIN_MAX_SCAN_BLOCKS, LITECOIN_MAX_SCAN_BYTES, LITECOIN_NETWORK,
5    LITECOIN_RETURN_LITOSHIS,
6};
7use crate::config::{RpcScope, ScanEnd, confirmations};
8use crate::error::{Context, Error, bail, ensure};
9use crate::journal;
10use crate::transaction::{decode as decode_transaction, transaction};
11use bitcoin::{
12    Amount, OutPoint, ScriptBuf, Transaction, TxOut, Witness,
13    consensus::serialize,
14    hashes::Hash,
15    secp256k1::{Keypair, Secp256k1, SecretKey},
16};
17use bitcoincore_rpc::{Auth, Client, RpcApi};
18use rand::{CryptoRng, RngCore};
19use serde::{Deserialize, Serialize};
20use serde_json::{Value, json};
21use std::{path::Path, str::FromStr};
22use urma_core::envelope;
23use urma_core::format::RecordKind;
24use urma_core::format::Urma;
25use urma_wallet::funding::Funding;
26
27use crate::{
28    backend::{Evidence, Family, Locator, Observation, RecordSource},
29    container,
30};
31
32#[derive(Clone, Debug, Serialize, Deserialize)]
33#[serde(deny_unknown_fields)]
34pub struct Quote {
35    pub network: String,
36    pub records: usize,
37    pub fee_rate_litoshi_vb: u64,
38    pub commit_fee_litoshis: u64,
39    pub reveal_fee_litoshis: u64,
40    pub total_fee_litoshis: u64,
41    pub minimum_funding_litoshis: u64,
42}
43
44#[derive(Clone, Serialize, Deserialize)]
45#[serde(deny_unknown_fields)]
46pub struct Plan {
47    pub network: String,
48    pub version: u8,
49    pub input_bytes: usize,
50    pub start_height: u64,
51    pub fee_rate_litoshi_vb: u64,
52    pub funding: Funding,
53    pub commit: String,
54    pub reveals: Vec<String>,
55}
56
57fn decode(raw: &str) -> Result<Transaction, Error> {
58    ensure!(
59        raw.len() <= 800_000,
60        "transparent transaction exceeds limit"
61    );
62    decode_transaction(raw, 800_000).map_err(|cause| match cause {
63        Error::Context { cause, .. } => Error::Context {
64            message: "invalid transparent transaction (MWEB funding is unsupported)".into(),
65            cause,
66        },
67        cause => cause,
68    })
69}
70
71pub fn quote(input_bytes: usize, rate: u64) -> Result<Quote, Error> {
72    if input_bytes > Limits::INPUT_BYTES {
73        return Err(Error::Capacity("client input capacity exceeded".into()));
74    }
75
76    ensure!(
77        (1..=Limits::INPUT_BYTES).contains(&input_bytes),
78        "input exceeds client publication capacity"
79    );
80    ensure!(
81        (1..=100).contains(&rate),
82        "fee rate must be 1..100 litoshi/vB"
83    );
84    let count = input_bytes.div_ceil(Urma::CHUNK_BYTES);
85    let secp = Secp256k1::new();
86    let signer = Keypair::from_secret_key(&secp, &SecretKey::from_slice(&[1; 32])?);
87    let mut sizing_record = vec![0; Urma::PRIVATE_RECORD_BYTES];
88    sizing_record[..8].copy_from_slice(&RecordKind::Private.prefix());
89    sizing_record[60..64].copy_from_slice(&1u32.to_le_bytes());
90    let (script, info) = envelope::build(&sizing_record, &signer)?;
91    let payout = ScriptBuf::new_p2wpkh(&bitcoin::WPubkeyHash::from_byte_array([0; 20]));
92    let mut reveal = transaction(
93        OutPoint::null(),
94        TxOut {
95            value: Amount::from_sat(LITECOIN_RETURN_LITOSHIS),
96            script_pubkey: payout.clone(),
97        },
98    );
99    reveal.input[0].witness = envelope::witness(&[0; 64], &script, &info)?;
100    let reveal_fee = u64::try_from(reveal.vsize())? * rate;
101    let mut commit = transaction(
102        OutPoint::null(),
103        TxOut {
104            value: Amount::from_sat(LITECOIN_RETURN_LITOSHIS),
105            script_pubkey: payout.clone(),
106        },
107    );
108    commit.output = vec![
109        TxOut {
110            value: Amount::ZERO,
111            script_pubkey: ScriptBuf::new_p2tr_tweaked(info.output_key())
112        };
113        count
114    ];
115    commit.output.push(TxOut {
116        value: Amount::ZERO,
117        script_pubkey: payout,
118    });
119    commit.input[0].witness = Witness::from_slice(&[vec![0; 73], vec![0; 33]]);
120    let commit_fee = u64::try_from(commit.vsize())? * rate;
121    let total = commit_fee + reveal_fee * u64::try_from(count)?;
122    ensure!(
123        total <= LITECOIN_MAX_FEE_LITOSHIS,
124        "client fee cap exceeded"
125    );
126    Ok(Quote {
127        network: LITECOIN_NETWORK.into(),
128        records: count,
129        fee_rate_litoshi_vb: rate,
130        commit_fee_litoshis: commit_fee,
131        reveal_fee_litoshis: reveal_fee * u64::try_from(count)?,
132        total_fee_litoshis: total,
133        minimum_funding_litoshis: total + LITECOIN_RETURN_LITOSHIS * (u64::try_from(count)? + 1),
134    })
135}
136
137pub fn prepare<R: RngCore + CryptoRng>(
138    records: &[Vec<u8>],
139    input_bytes: usize,
140    funding: Funding,
141    rate: u64,
142    start_height: u64,
143    rng: &mut R,
144) -> Result<Plan, Error> {
145    let costs = quote(input_bytes, rate)?;
146    ensure!(
147        records.len() == costs.records,
148        "record count disagrees with input length"
149    );
150    let first = container::inspect_header(&records[0])?;
151    for (i, record) in records.iter().enumerate() {
152        let h = container::inspect_header(record)?;
153        ensure!(
154            h.id == first.id
155                && h.discovery_tag == first.discovery_tag
156                && usize::try_from(h.index)? == i
157                && usize::try_from(h.count)? == records.len(),
158            "expected one ordered complete opaque object"
159        );
160    }
161    let (outpoint, previous) = funding.prevout()?;
162    ensure!(
163        previous.value.to_sat() >= costs.minimum_funding_litoshis,
164        "insufficient test funding"
165    );
166    let payout = &previous.script_pubkey;
167    let mut pending = Vec::new();
168    let mut commit = transaction(
169        outpoint,
170        TxOut {
171            value: Amount::from_sat(LITECOIN_RETURN_LITOSHIS),
172            script_pubkey: payout.clone(),
173        },
174    );
175    commit.output.clear();
176    for record in records {
177        let prepared = PreparedReveal::new(record, payout, rate, LITECOIN_RETURN_LITOSHIS, rng)?;
178        commit.output.push(prepared.output());
179        pending.push(prepared);
180    }
181    commit.output.push(TxOut {
182        value: Amount::from_sat(
183            previous.value.to_sat()
184                - costs.total_fee_litoshis
185                - LITECOIN_RETURN_LITOSHIS * u64::try_from(costs.records)?,
186        ),
187        script_pubkey: payout.clone(),
188    });
189    let mut reveals = Vec::new();
190    for (index, prepared) in pending.into_iter().enumerate() {
191        reveals.push(hex::encode(serialize(&prepared.sign(
192            &commit,
193            u32::try_from(index)?,
194            payout,
195        )?)));
196    }
197    let plan = Plan {
198        network: LITECOIN_NETWORK.into(),
199        version: Urma::VERSION,
200        input_bytes,
201        start_height,
202        fee_rate_litoshi_vb: rate,
203        funding,
204        commit: hex::encode(serialize(&commit)),
205        reveals,
206    };
207    inspect(&plan, false)?;
208    Ok(plan)
209}
210
211pub fn inspect(plan: &Plan, signed: bool) -> Result<Value, Error> {
212    ensure!(
213        plan.network == LITECOIN_NETWORK,
214        "only Litecoin testnet plans are accepted"
215    );
216    let costs = quote(plan.input_bytes, plan.fee_rate_litoshi_vb)?;
217    ensure!(plan.reveals.len() == costs.records, "invalid reveal count");
218    let (_, previous) = plan.funding.prevout()?;
219    let reveal = decode(&plan.reveals[0])?;
220    let witness = &reveal
221        .input
222        .first()
223        .context("missing reveal input")?
224        .witness;
225    let record = envelope::extract(witness)?.record;
226    let header = container::inspect_header(&record)?;
227    let common = journal::Plan {
228        version: plan.version,
229        network: LITECOIN_NETWORK.into(),
230        object_id: hex::encode(header.id),
231        start_height: plan.start_height,
232        input_bytes: plan.input_bytes,
233        fee_sats: costs.total_fee_litoshis,
234        commit_fee_sats: costs.commit_fee_litoshis,
235        reveal_fee_sats: costs.reveal_fee_litoshis,
236        fee_rate_sat_vb: plan.fee_rate_litoshi_vb,
237        funding: plan.funding.clone(),
238        commit: plan.commit.clone(),
239        reveals: plan.reveals.clone(),
240        mining_address: String::new(),
241    };
242    let checked = journal::validate_transparent_plan(&common, &previous.script_pubkey, signed)?;
243    Ok(
244        json!({"network":LITECOIN_NETWORK, "status":if signed {"signed"} else {"draft"},
245        "broadcast":false, "object_id":checked["object_id"], "start_height":plan.start_height,
246        "commit_txid":checked["commit_txid"], "reveal_txids":checked["reveal_txids"],
247        "commit_vsize":checked["commit_vsize"], "reveal_vsizes":checked["reveal_vsizes"],
248        "quote":costs, "funding_litoshis":previous.value.to_sat(),
249        "content_authenticated":false, "chain_status_checked":false}),
250    )
251}
252
253pub fn is_signed(plan: &Plan) -> Result<bool, Error> {
254    Ok(!decode(&plan.commit)?
255        .input
256        .first()
257        .context("missing commit input")?
258        .witness
259        .is_empty())
260}
261
262pub trait Rpc {
263    fn call(&self, method: &str, args: &[Value]) -> Result<Value, Error>;
264}
265
266pub struct Core {
267    client: Client,
268}
269
270impl Core {
271    pub fn connect(endpoint: &str, cookie: &Path, wallet: RpcScope<'_>) -> Result<Self, Error> {
272        let mut url = url::Url::parse(endpoint).context("invalid local RPC URL")?;
273        match (url.password(), url.query(), url.fragment()) {
274            (None, None, None) => {}
275            components => {
276                bail!("URL credentials, query and fragment are forbidden: {components:?}")
277            }
278        }
279        ensure!(
280            url.scheme() == "http" && url.username().is_empty() && url.path() == "/",
281            "RPC requires a plain local HTTP origin and cookie authentication"
282        );
283        let host = url.host_str().context("missing RPC host")?;
284        let host = host.trim_matches(['[', ']']);
285        ensure!(
286            host.parse::<std::net::IpAddr>()?.is_loopback(),
287            "RPC must use a loopback IP"
288        );
289        if let RpcScope::Wallet(wallet) = wallet {
290            ensure!(!wallet.is_empty(), "wallet name is empty");
291            url.path_segments_mut()
292                .map_err(|unit| Error::Invalid(format!("invalid wallet URL: {unit:?}")))?
293                .push("wallet")
294                .push(wallet);
295        }
296        let client = Client::new(url.as_str(), Auth::CookieFile(cookie.to_owned()))
297            .context("cannot configure local cookie-authenticated RPC")?;
298        let core = Self { client };
299        check_node(&core, false)?;
300        Ok(core)
301    }
302}
303
304impl Rpc for Core {
305    fn call(&self, method: &str, args: &[Value]) -> Result<Value, Error> {
306        ensure!(
307            [
308                "getblockchaininfo",
309                "getblockhash",
310                "getblock",
311                "getrawtransaction",
312                "gettxout",
313                "getaddressinfo",
314                "getnewaddress",
315                "signrawtransactionwithwallet",
316                "testmempoolaccept",
317                "sendrawtransaction"
318            ]
319            .contains(&method),
320            "unsupported Litecoin RPC method"
321        );
322        match self.client.call::<Value>(method, args).map_err(Error::from) {
323            Ok(v) => Ok(v),
324            Err(error) if method == "getrawtransaction" && error.is_missing_transaction() => {
325                tracing::warn!(%error, "transaction absent from node");
326                Ok(Value::Null)
327            }
328            Err(error) => Err(error),
329        }
330    }
331}
332
333pub fn check_node(rpc: &dyn Rpc, require_ready: bool) -> Result<Value, Error> {
334    let info = rpc.call("getblockchaininfo", &[])?;
335    ensure!(info["chain"] == "test", "RPC must be Litecoin testnet");
336    ensure!(
337        rpc.call("getblockhash", &[json!(0)])?
338            == urma_chain::observation::Chain::LitecoinTestnet
339                .genesis()?
340                .0
341                .to_string(),
342        "Litecoin testnet genesis mismatch"
343    );
344    if require_ready {
345        ensure!(
346            info["initialblockdownload"] == false
347                && info["blocks"].as_u64().context("missing block height")? > 0,
348            "node is not synchronized"
349        );
350        ensure!(
351            info["softforks"]["segwit"]["active"] == true
352                && info["softforks"]["taproot"]["active"] == true,
353            "SegWit and Taproot must be active"
354        );
355    }
356    Ok(info)
357}
358
359pub fn receiving_address(rpc: &dyn Rpc) -> Result<String, Error> {
360    check_node(rpc, false)?;
361    let value = rpc.call(
362        "getnewaddress",
363        &[json!("urma-litecoin-testnet"), json!("bech32")],
364    )?;
365    let address = value.as_str().context("missing testnet address")?;
366    ensure!(
367        address.starts_with("tltc1q"),
368        "wallet returned a non-testnet/non-P2WPKH address"
369    );
370    let info = rpc.call("getaddressinfo", &[json!(address)])?;
371    let script = ScriptBuf::from_bytes(hex::decode(
372        info["scriptPubKey"]
373            .as_str()
374            .context("missing address script")?,
375    )?);
376    ensure!(
377        info["ismine"] == true && script.is_p2wpkh(),
378        "address must belong to a transparent test wallet"
379    );
380    Ok(address.to_owned())
381}
382
383pub fn sign(rpc: &dyn Rpc, draft: &Plan) -> Result<Plan, Error> {
384    inspect(draft, false)?;
385    check_node(rpc, false)?;
386    let (outpoint, previous) = draft.funding.prevout()?;
387    let result = rpc.call("signrawtransactionwithwallet", &[json!(draft.commit), json!([{
388        "txid":outpoint.txid, "vout":outpoint.vout, "scriptPubKey":hex::encode(previous.script_pubkey.as_bytes()),
389        "amount":coin_value(previous.value.to_sat())?
390    }]), json!("ALL")])?;
391    ensure!(
392        result["complete"] == true,
393        "wallet did not fully sign commit"
394    );
395    let mut signed = draft.clone();
396    signed.commit = result["hex"]
397        .as_str()
398        .context("missing signed commit")?
399        .to_owned();
400    ensure!(
401        decode(&signed.commit)?.compute_txid() == decode(&draft.commit)?.compute_txid(),
402        "signer changed commit body"
403    );
404    inspect(&signed, true)?;
405    Ok(signed)
406}
407
408fn coin_value(litoshis: u64) -> Result<Value, Error> {
409    serde_json::from_str(&format!(
410        "{}.{:08}",
411        litoshis / 100_000_000,
412        litoshis % 100_000_000
413    ))
414    .map_err(Into::into)
415}
416
417fn litoshis(value: &Value) -> Result<u64, Error> {
418    let text = value.to_string();
419    Amount::from_str_in(&text, bitcoin::Denomination::Bitcoin)
420        .map(|amount| amount.to_sat())
421        .context("invalid whole-litoshi RPC amount")
422}
423
424pub struct LitecoinRecords<'a> {
425    pub rpc: &'a dyn Rpc,
426    pub start_height: u64,
427}
428
429impl RecordSource for LitecoinRecords<'_> {
430    fn read_records(
431        &self,
432        emit: &mut dyn FnMut(&[u8]) -> Result<(), Error>,
433    ) -> Result<Observation, Error> {
434        self.read_range(
435            ScanEnd::Tip,
436            &mut |height| {
437                tracing::trace!(height, "scan progress");
438                Ok(())
439            },
440            emit,
441        )
442    }
443}
444
445impl LitecoinRecords<'_> {
446    pub fn read_range(
447        &self,
448        end: ScanEnd,
449        progress: &mut dyn FnMut(u64) -> Result<(), Error>,
450        emit: &mut dyn FnMut(&[u8]) -> Result<(), Error>,
451    ) -> Result<Observation, Error> {
452        let info = check_node(self.rpc, true)?;
453        let current_tip = info["blocks"].as_u64().context("missing tip height")?;
454        let tip = match end {
455            ScanEnd::Tip => current_tip,
456            ScanEnd::Height(height) => height,
457        };
458        ensure!(tip <= current_tip, "end height beyond tip");
459        ensure!(
460            self.start_height <= tip && tip - self.start_height < LITECOIN_MAX_SCAN_BLOCKS,
461            "scan interval exceeds limits"
462        );
463        let tip_hash = self.rpc.call("getblockhash", &[json!(tip)])?;
464        let tip_hash = parse_hash(&tip_hash)?;
465        let mut previous = if self.start_height == 0 {
466            bitcoin::BlockHash::all_zeros()
467        } else {
468            parse_hash(
469                &self
470                    .rpc
471                    .call("getblockhash", &[json!(self.start_height - 1)])?,
472            )?
473        };
474        let mut scanned_bytes = 0usize;
475        for height in self.start_height..=tip {
476            progress(height)?;
477            let hash = parse_hash(&self.rpc.call("getblockhash", &[json!(height)])?)?;
478            let block = self
479                .rpc
480                .call("getblock", &[json!(hash.to_string()), json!(2)])?;
481            let bytes = serde_json::to_vec(&block)?.len();
482            ensure!(bytes <= 32 * 1024 * 1024, "block JSON exceeds limit");
483            scanned_bytes = scanned_bytes
484                .checked_add(bytes)
485                .context("scan byte overflow")?;
486            ensure!(
487                scanned_bytes <= LITECOIN_MAX_SCAN_BYTES,
488                "scan byte limit exceeded"
489            );
490            ensure!(
491                parse_hash(&block["hash"])? == hash
492                    && block["height"] == height
493                    && block["confirmations"]
494                        .as_i64()
495                        .context("missing block confirmations")?
496                        > 0,
497                "invalid selected block response"
498            );
499            if height > 0 {
500                ensure!(
501                    parse_hash(&block["previousblockhash"])? == previous,
502                    "chain changed during scan"
503                );
504            }
505            emit_block_records(&block, emit)?;
506            previous = hash;
507        }
508        ensure!(
509            previous == tip_hash
510                && parse_hash(&self.rpc.call("getblockhash", &[json!(tip)])?)? == tip_hash,
511            "chain changed during scan"
512        );
513        Ok(Observation {
514            backend: Family::Litecoin,
515            locator: Locator::BlockRange {
516                network: LITECOIN_NETWORK.into(),
517                start_height: self.start_height,
518                tip_height: tip,
519                tip_hash: tip_hash.to_string(),
520            },
521            evidence: Evidence::LitecoinCoreTrusted,
522            scanned_bytes,
523        })
524    }
525}
526
527fn parse_hash(value: &Value) -> Result<bitcoin::BlockHash, Error> {
528    bitcoin::BlockHash::from_str(value.as_str().context("missing block hash")?)
529        .context("invalid block hash")
530}
531
532fn transaction_status(rpc: &dyn Rpc, raw: &str) -> Result<Value, Error> {
533    let expected = decode(raw)?;
534    let txid = expected.compute_txid();
535    let value = rpc.call("getrawtransaction", &[json!(txid.to_string()), json!(true)])?;
536    if value.is_null() {
537        return Ok(json!({"txid":txid, "state":"unknown", "confirmations":0}));
538    }
539    let observed = decode(
540        value["hex"]
541            .as_str()
542            .context("missing observed transaction")?,
543    )?;
544    ensure!(
545        observed == expected,
546        "node returned different transaction/witness bytes"
547    );
548    let confirmations = confirmations(&value)?;
549    ensure!(confirmations >= 0, "transaction conflicted or reorganized");
550    if confirmations > 0 {
551        let hash = parse_hash(&value["blockhash"])?;
552        let block = rpc.call("getblock", &[json!(hash.to_string()), json!(1)])?;
553        let height = block["height"]
554            .as_u64()
555            .context("missing confirmation height")?;
556        ensure!(
557            parse_hash(&rpc.call("getblockhash", &[json!(height)])?)? == hash
558                && block["tx"]
559                    .as_array()
560                    .context("missing txids")?
561                    .contains(&json!(txid.to_string())),
562            "confirmation not in selected chain"
563        );
564    }
565    Ok(
566        json!({"txid":txid, "state":if confirmations > 0 {"confirmed"} else {"pending"}, "confirmations":confirmations}),
567    )
568}
569
570pub fn status(rpc: &dyn Rpc, plan: &Plan) -> Result<Value, Error> {
571    inspect(plan, true)?;
572    check_node(rpc, true)?;
573    let commit = transaction_status(rpc, &plan.commit)?;
574    let reveals = plan
575        .reveals
576        .iter()
577        .map(|raw| transaction_status(rpc, raw))
578        .collect::<Result<Vec<_>, Error>>()?;
579    Ok(
580        json!({"network":LITECOIN_NETWORK, "commit":commit, "reveals":reveals,
581        "evidence":"litecoin_core_trusted", "quote":quote(plan.input_bytes,plan.fee_rate_litoshi_vb)?}),
582    )
583}
584
585pub fn broadcast(rpc: &dyn Rpc, plan: &Plan, budget: u64) -> Result<Value, Error> {
586    inspect(plan, true)?;
587    ensure!(
588        quote(plan.input_bytes, plan.fee_rate_litoshi_vb)?.total_fee_litoshis <= budget
589            && budget <= LITECOIN_MAX_FEE_LITOSHIS,
590        "explicit fee budget missing or exceeded"
591    );
592    check_node(rpc, true)?;
593    let state = transaction_status(rpc, &plan.commit)?;
594    if state["state"] == "unknown" {
595        let (outpoint, previous) = plan.funding.prevout()?;
596        let coin = rpc.call(
597            "gettxout",
598            &[json!(outpoint.txid), json!(outpoint.vout), json!(true)],
599        )?;
600        ensure!(
601            coin["confirmations"]
602                .as_u64()
603                .context("missing funding confirmations")?
604                > 0
605                && (coin["coinbase"] == false
606                    || (coin["coinbase"] == true
607                        && coin["confirmations"]
608                            .as_u64()
609                            .context("missing coinbase confirmations")?
610                            >= 100))
611                && coin["scriptPubKey"]["hex"] == hex::encode(previous.script_pubkey.as_bytes())
612                && litoshis(&coin["value"])? == previous.value.to_sat(),
613            "funding must be confirmed, unspent and match the plan"
614        );
615        submit(rpc, &plan.commit)?;
616        return Ok(
617            json!({"network":LITECOIN_NETWORK, "status":"commit_submitted", "commit_txid":state["txid"], "broadcast":true}),
618        );
619    }
620    if state["state"] == "pending" {
621        return Ok(
622            json!({"network":LITECOIN_NETWORK,"status":"awaiting_commit_confirmation","broadcast":false}),
623        );
624    }
625    let mut submitted = Vec::new();
626    for raw in &plan.reveals {
627        let observed = transaction_status(rpc, raw)?;
628        if observed["state"] == "unknown" {
629            submitted.push(submit(rpc, raw)?);
630        }
631    }
632    Ok(
633        json!({"network":LITECOIN_NETWORK,"status":"reveals_checked","submitted_txids":submitted,
634        "note":"Submission is not confirmation or successful recovery; check status and recover independently."}),
635    )
636}
637
638fn submit(rpc: &dyn Rpc, raw: &str) -> Result<String, Error> {
639    let expected = decode(raw)?.compute_txid().to_string();
640    let acceptance = rpc.call("testmempoolaccept", &[json!([raw])])?;
641    ensure!(
642        acceptance[0]["allowed"] == true && acceptance[0]["txid"] == expected,
643        "node policy rejected transaction; nothing submitted"
644    );
645    let returned = rpc.call("sendrawtransaction", &[json!(raw)])?;
646    ensure!(
647        returned == expected,
648        "ambiguous submission result; inspect status before retrying"
649    );
650    Ok(expected)
651}
652fn emit_block_records(
653    block: &Value,
654    emit: &mut dyn FnMut(&[u8]) -> Result<(), Error>,
655) -> Result<(), Error> {
656    let transactions = block["tx"]
657        .as_array()
658        .context("missing block transactions")?;
659    for tx in transactions {
660        for input in tx["vin"].as_array().context("missing transparent inputs")? {
661            let Some(stack) = input.get("txinwitness") else {
662                continue;
663            };
664            let stack = stack.as_array().context("invalid witness array")?;
665            if stack.len() != 3 {
666                continue;
667            }
668            let mut parts = Vec::with_capacity(3);
669            for (i, part) in stack.iter().enumerate() {
670                let encoded = part.as_str().context("invalid witness hex")?;
671                let limit = [128, (Urma::PRIVATE_RECORD_BYTES + 1024) * 2, 66][i];
672                if encoded.len() > limit {
673                    break;
674                }
675                parts.push(hex::decode(encoded)?);
676            }
677            if parts.len() == 3 {
678                let witness = Witness::from_slice(&parts);
679                if envelope::is_candidate(&witness) {
680                    match extract_core_envelope(tx, &witness) {
681                        Ok(parsed) => emit(&parsed.record)?,
682                        Err(error) => {
683                            tracing::warn!(%error, "invalid URMA envelope rejected")
684                        }
685                    }
686                }
687            }
688        }
689    }
690    Ok(())
691}
692
693fn extract_core_envelope(tx: &Value, witness: &Witness) -> Result<envelope::ParsedEnvelope, Error> {
694    let inputs = tx["vin"].as_array().context("missing transparent inputs")?;
695    let outputs = tx["vout"]
696        .as_array()
697        .context("missing transparent outputs")?;
698    let version = i32::try_from(
699        tx["version"]
700            .as_i64()
701            .context("invalid reveal transaction shape")?,
702    )?;
703    envelope::validate_reveal_shape(version, inputs.len(), outputs.len())?;
704    let script_sig = ScriptBuf::from_bytes(hex::decode(
705        inputs[0]["scriptSig"]["hex"]
706            .as_str()
707            .context("missing scriptSig")?,
708    )?);
709    let script = ScriptBuf::from_bytes(hex::decode(
710        outputs[0]["scriptPubKey"]["hex"]
711            .as_str()
712            .context("missing return script")?,
713    )?);
714    envelope::validate_reveal_scripts(&script_sig, &script)?;
715    Ok(envelope::extract(witness)?)
716}