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}