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) {
323            Ok(v) => Ok(v),
324            Err(bitcoincore_rpc::Error::JsonRpc(bitcoincore_rpc::jsonrpc::Error::Rpc(e)))
325                if method == "getrawtransaction" && e.code == -5 =>
326            {
327                tracing::warn!(code = e.code, "transaction absent from node");
328                Ok(Value::Null)
329            }
330            Err(error) => Err(error.into()),
331        }
332    }
333}
334
335pub fn check_node(rpc: &dyn Rpc, require_ready: bool) -> Result<Value, Error> {
336    let info = rpc.call("getblockchaininfo", &[])?;
337    ensure!(info["chain"] == "test", "RPC must be Litecoin testnet");
338    ensure!(
339        rpc.call("getblockhash", &[json!(0)])?
340            == urma_chain::observation::Chain::LitecoinTestnet
341                .genesis()?
342                .0
343                .to_string(),
344        "Litecoin testnet genesis mismatch"
345    );
346    if require_ready {
347        ensure!(
348            info["initialblockdownload"] == false
349                && info["blocks"].as_u64().context("missing block height")? > 0,
350            "node is not synchronized"
351        );
352        ensure!(
353            info["softforks"]["segwit"]["active"] == true
354                && info["softforks"]["taproot"]["active"] == true,
355            "SegWit and Taproot must be active"
356        );
357    }
358    Ok(info)
359}
360
361pub fn receiving_address(rpc: &dyn Rpc) -> Result<String, Error> {
362    check_node(rpc, false)?;
363    let value = rpc.call(
364        "getnewaddress",
365        &[json!("urma-litecoin-testnet"), json!("bech32")],
366    )?;
367    let address = value.as_str().context("missing testnet address")?;
368    ensure!(
369        address.starts_with("tltc1q"),
370        "wallet returned a non-testnet/non-P2WPKH address"
371    );
372    let info = rpc.call("getaddressinfo", &[json!(address)])?;
373    let script = ScriptBuf::from_bytes(hex::decode(
374        info["scriptPubKey"]
375            .as_str()
376            .context("missing address script")?,
377    )?);
378    ensure!(
379        info["ismine"] == true && script.is_p2wpkh(),
380        "address must belong to a transparent test wallet"
381    );
382    Ok(address.to_owned())
383}
384
385pub fn sign(rpc: &dyn Rpc, draft: &Plan) -> Result<Plan, Error> {
386    inspect(draft, false)?;
387    check_node(rpc, false)?;
388    let (outpoint, previous) = draft.funding.prevout()?;
389    let result = rpc.call("signrawtransactionwithwallet", &[json!(draft.commit), json!([{
390        "txid":outpoint.txid, "vout":outpoint.vout, "scriptPubKey":hex::encode(previous.script_pubkey.as_bytes()),
391        "amount":coin_value(previous.value.to_sat())?
392    }]), json!("ALL")])?;
393    ensure!(
394        result["complete"] == true,
395        "wallet did not fully sign commit"
396    );
397    let mut signed = draft.clone();
398    signed.commit = result["hex"]
399        .as_str()
400        .context("missing signed commit")?
401        .to_owned();
402    ensure!(
403        decode(&signed.commit)?.compute_txid() == decode(&draft.commit)?.compute_txid(),
404        "signer changed commit body"
405    );
406    inspect(&signed, true)?;
407    Ok(signed)
408}
409
410fn coin_value(litoshis: u64) -> Result<Value, Error> {
411    serde_json::from_str(&format!(
412        "{}.{:08}",
413        litoshis / 100_000_000,
414        litoshis % 100_000_000
415    ))
416    .map_err(Into::into)
417}
418
419fn litoshis(value: &Value) -> Result<u64, Error> {
420    let text = value.to_string();
421    Amount::from_str_in(&text, bitcoin::Denomination::Bitcoin)
422        .map(|amount| amount.to_sat())
423        .context("invalid whole-litoshi RPC amount")
424}
425
426pub struct LitecoinRecords<'a> {
427    pub rpc: &'a dyn Rpc,
428    pub start_height: u64,
429}
430
431impl RecordSource for LitecoinRecords<'_> {
432    fn read_records(
433        &self,
434        emit: &mut dyn FnMut(&[u8]) -> Result<(), Error>,
435    ) -> Result<Observation, Error> {
436        self.read_range(
437            ScanEnd::Tip,
438            &mut |height| {
439                tracing::trace!(height, "scan progress");
440                Ok(())
441            },
442            emit,
443        )
444    }
445}
446
447impl LitecoinRecords<'_> {
448    pub fn read_range(
449        &self,
450        end: ScanEnd,
451        progress: &mut dyn FnMut(u64) -> Result<(), Error>,
452        emit: &mut dyn FnMut(&[u8]) -> Result<(), Error>,
453    ) -> Result<Observation, Error> {
454        let info = check_node(self.rpc, true)?;
455        let current_tip = info["blocks"].as_u64().context("missing tip height")?;
456        let tip = match end {
457            ScanEnd::Tip => current_tip,
458            ScanEnd::Height(height) => height,
459        };
460        ensure!(tip <= current_tip, "end height beyond tip");
461        ensure!(
462            self.start_height <= tip && tip - self.start_height < LITECOIN_MAX_SCAN_BLOCKS,
463            "scan interval exceeds limits"
464        );
465        let tip_hash = self.rpc.call("getblockhash", &[json!(tip)])?;
466        let tip_hash = parse_hash(&tip_hash)?;
467        let mut previous = if self.start_height == 0 {
468            bitcoin::BlockHash::all_zeros()
469        } else {
470            parse_hash(
471                &self
472                    .rpc
473                    .call("getblockhash", &[json!(self.start_height - 1)])?,
474            )?
475        };
476        let mut scanned_bytes = 0usize;
477        for height in self.start_height..=tip {
478            progress(height)?;
479            let hash = parse_hash(&self.rpc.call("getblockhash", &[json!(height)])?)?;
480            let block = self
481                .rpc
482                .call("getblock", &[json!(hash.to_string()), json!(2)])?;
483            let bytes = serde_json::to_vec(&block)?.len();
484            ensure!(bytes <= 32 * 1024 * 1024, "block JSON exceeds limit");
485            scanned_bytes = scanned_bytes
486                .checked_add(bytes)
487                .context("scan byte overflow")?;
488            ensure!(
489                scanned_bytes <= LITECOIN_MAX_SCAN_BYTES,
490                "scan byte limit exceeded"
491            );
492            ensure!(
493                parse_hash(&block["hash"])? == hash
494                    && block["height"] == height
495                    && block["confirmations"]
496                        .as_i64()
497                        .context("missing block confirmations")?
498                        > 0,
499                "invalid selected block response"
500            );
501            if height > 0 {
502                ensure!(
503                    parse_hash(&block["previousblockhash"])? == previous,
504                    "chain changed during scan"
505                );
506            }
507            emit_block_records(&block, emit)?;
508            previous = hash;
509        }
510        ensure!(
511            previous == tip_hash
512                && parse_hash(&self.rpc.call("getblockhash", &[json!(tip)])?)? == tip_hash,
513            "chain changed during scan"
514        );
515        Ok(Observation {
516            backend: Family::Litecoin,
517            locator: Locator::BlockRange {
518                network: LITECOIN_NETWORK.into(),
519                start_height: self.start_height,
520                tip_height: tip,
521                tip_hash: tip_hash.to_string(),
522            },
523            evidence: Evidence::LitecoinCoreTrusted,
524            scanned_bytes,
525        })
526    }
527}
528
529fn parse_hash(value: &Value) -> Result<bitcoin::BlockHash, Error> {
530    bitcoin::BlockHash::from_str(value.as_str().context("missing block hash")?)
531        .context("invalid block hash")
532}
533
534fn transaction_status(rpc: &dyn Rpc, raw: &str) -> Result<Value, Error> {
535    let expected = decode(raw)?;
536    let txid = expected.compute_txid();
537    let value = rpc.call("getrawtransaction", &[json!(txid.to_string()), json!(true)])?;
538    if value.is_null() {
539        return Ok(json!({"txid":txid, "state":"unknown", "confirmations":0}));
540    }
541    let observed = decode(
542        value["hex"]
543            .as_str()
544            .context("missing observed transaction")?,
545    )?;
546    ensure!(
547        observed == expected,
548        "node returned different transaction/witness bytes"
549    );
550    let confirmations = confirmations(&value)?;
551    ensure!(confirmations >= 0, "transaction conflicted or reorganized");
552    if confirmations > 0 {
553        let hash = parse_hash(&value["blockhash"])?;
554        let block = rpc.call("getblock", &[json!(hash.to_string()), json!(1)])?;
555        let height = block["height"]
556            .as_u64()
557            .context("missing confirmation height")?;
558        ensure!(
559            parse_hash(&rpc.call("getblockhash", &[json!(height)])?)? == hash
560                && block["tx"]
561                    .as_array()
562                    .context("missing txids")?
563                    .contains(&json!(txid.to_string())),
564            "confirmation not in selected chain"
565        );
566    }
567    Ok(
568        json!({"txid":txid, "state":if confirmations > 0 {"confirmed"} else {"pending"}, "confirmations":confirmations}),
569    )
570}
571
572pub fn status(rpc: &dyn Rpc, plan: &Plan) -> Result<Value, Error> {
573    inspect(plan, true)?;
574    check_node(rpc, true)?;
575    let commit = transaction_status(rpc, &plan.commit)?;
576    let reveals = plan
577        .reveals
578        .iter()
579        .map(|raw| transaction_status(rpc, raw))
580        .collect::<Result<Vec<_>, Error>>()?;
581    Ok(
582        json!({"network":LITECOIN_NETWORK, "commit":commit, "reveals":reveals,
583        "evidence":"litecoin_core_trusted", "quote":quote(plan.input_bytes,plan.fee_rate_litoshi_vb)?}),
584    )
585}
586
587pub fn broadcast(rpc: &dyn Rpc, plan: &Plan, budget: u64) -> Result<Value, Error> {
588    inspect(plan, true)?;
589    ensure!(
590        quote(plan.input_bytes, plan.fee_rate_litoshi_vb)?.total_fee_litoshis <= budget
591            && budget <= LITECOIN_MAX_FEE_LITOSHIS,
592        "explicit fee budget missing or exceeded"
593    );
594    check_node(rpc, true)?;
595    let state = transaction_status(rpc, &plan.commit)?;
596    if state["state"] == "unknown" {
597        let (outpoint, previous) = plan.funding.prevout()?;
598        let coin = rpc.call(
599            "gettxout",
600            &[json!(outpoint.txid), json!(outpoint.vout), json!(true)],
601        )?;
602        ensure!(
603            coin["confirmations"]
604                .as_u64()
605                .context("missing funding confirmations")?
606                > 0
607                && (coin["coinbase"] == false
608                    || (coin["coinbase"] == true
609                        && coin["confirmations"]
610                            .as_u64()
611                            .context("missing coinbase confirmations")?
612                            >= 100))
613                && coin["scriptPubKey"]["hex"] == hex::encode(previous.script_pubkey.as_bytes())
614                && litoshis(&coin["value"])? == previous.value.to_sat(),
615            "funding must be confirmed, unspent and match the plan"
616        );
617        submit(rpc, &plan.commit)?;
618        return Ok(
619            json!({"network":LITECOIN_NETWORK, "status":"commit_submitted", "commit_txid":state["txid"], "broadcast":true}),
620        );
621    }
622    if state["state"] == "pending" {
623        return Ok(
624            json!({"network":LITECOIN_NETWORK,"status":"awaiting_commit_confirmation","broadcast":false}),
625        );
626    }
627    let mut submitted = Vec::new();
628    for raw in &plan.reveals {
629        let observed = transaction_status(rpc, raw)?;
630        if observed["state"] == "unknown" {
631            submitted.push(submit(rpc, raw)?);
632        }
633    }
634    Ok(
635        json!({"network":LITECOIN_NETWORK,"status":"reveals_checked","submitted_txids":submitted,
636        "note":"Submission is not confirmation or successful recovery; check status and recover independently."}),
637    )
638}
639
640fn submit(rpc: &dyn Rpc, raw: &str) -> Result<String, Error> {
641    let expected = decode(raw)?.compute_txid().to_string();
642    let acceptance = rpc.call("testmempoolaccept", &[json!([raw])])?;
643    ensure!(
644        acceptance[0]["allowed"] == true && acceptance[0]["txid"] == expected,
645        "node policy rejected transaction; nothing submitted"
646    );
647    let returned = rpc.call("sendrawtransaction", &[json!(raw)])?;
648    ensure!(
649        returned == expected,
650        "ambiguous submission result; inspect status before retrying"
651    );
652    Ok(expected)
653}
654fn emit_block_records(
655    block: &Value,
656    emit: &mut dyn FnMut(&[u8]) -> Result<(), Error>,
657) -> Result<(), Error> {
658    let transactions = block["tx"]
659        .as_array()
660        .context("missing block transactions")?;
661    for tx in transactions {
662        for input in tx["vin"].as_array().context("missing transparent inputs")? {
663            let Some(stack) = input.get("txinwitness") else {
664                continue;
665            };
666            let stack = stack.as_array().context("invalid witness array")?;
667            if stack.len() != 3 {
668                continue;
669            }
670            let mut parts = Vec::with_capacity(3);
671            for (i, part) in stack.iter().enumerate() {
672                let encoded = part.as_str().context("invalid witness hex")?;
673                let limit = [128, (Urma::PRIVATE_RECORD_BYTES + 1024) * 2, 66][i];
674                if encoded.len() > limit {
675                    break;
676                }
677                parts.push(hex::decode(encoded)?);
678            }
679            if parts.len() == 3 {
680                let witness = Witness::from_slice(&parts);
681                if envelope::is_candidate(&witness) {
682                    match extract_core_envelope(tx, &witness) {
683                        Ok(parsed) => emit(&parsed.record)?,
684                        Err(error) => {
685                            tracing::warn!(%error, "invalid URMA envelope rejected")
686                        }
687                    }
688                }
689            }
690        }
691    }
692    Ok(())
693}
694
695fn extract_core_envelope(tx: &Value, witness: &Witness) -> Result<envelope::ParsedEnvelope, Error> {
696    let inputs = tx["vin"].as_array().context("missing transparent inputs")?;
697    let outputs = tx["vout"]
698        .as_array()
699        .context("missing transparent outputs")?;
700    let version = i32::try_from(
701        tx["version"]
702            .as_i64()
703            .context("invalid reveal transaction shape")?,
704    )?;
705    envelope::validate_reveal_shape(version, inputs.len(), outputs.len())?;
706    let script_sig = ScriptBuf::from_bytes(hex::decode(
707        inputs[0]["scriptSig"]["hex"]
708            .as_str()
709            .context("missing scriptSig")?,
710    )?);
711    let script = ScriptBuf::from_bytes(hex::decode(
712        outputs[0]["scriptPubKey"]["hex"]
713            .as_str()
714            .context("missing return script")?,
715    )?);
716    envelope::validate_reveal_scripts(&script_sig, &script)?;
717    Ok(envelope::extract(witness)?)
718}