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}