diff --git a/Cargo.lock b/Cargo.lock index 84d23cc..3b1ca83 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -581,6 +581,7 @@ dependencies = [ "redact", "rust-bitvmx-storage-backend", "serde", + "serde_yaml", "thiserror 2.0.20", "tracing", "tracing-subscriber", @@ -3343,6 +3344,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "salsa20" version = "0.10.2" @@ -3605,6 +3612,19 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "serdect" version = "0.3.0" @@ -4232,6 +4252,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/Cargo.toml b/Cargo.toml index 7c46a62..7cb5e09 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ hex = "0.4.3" itertools = "0.15.0" musig2 = { version = "0.4.1", features = ["secp256k1"] } serde = { version = "1.0.229", features = ["derive"] } +serde_yaml = "0.9" thiserror = "2.0.20" tracing = "0.1.44" tracing-subscriber = "0.3.23" diff --git a/README.md b/README.md index 10ac007..0575319 100644 --- a/README.md +++ b/README.md @@ -289,6 +289,26 @@ fn render(protocol: &Protocol) -> anyhow::Result<()> { `GraphOptions::Default` renders each transaction as a node labeled with indexed inputs/outputs and their satoshi values. Use `GraphOptions::EdgeArrows` to include port-specific arrows that highlight which output feeds each downstream input. +### Export to BitVMX Protocol Studio + +`Protocol::export_studio_yaml` returns the serialized YAML. Set an output directory +to also write the graph as `.yaml`. + +```rust +use protocol_builder::graph::studio_yaml::StudioExportSettings; + +let yaml = protocol.export_studio_yaml( + StudioExportSettings::default().with_output_dir("studio-graphs"), +)?; +``` + +Applications may populate the directory from their own configuration. Setting +`PROTOCOL_BUILDER_EXPORT_DIR` also supplies it to `StudioExportSettings::default()`; +in that case, calls to `Protocol::visualize` automatically write the corresponding +Studio YAML file without changing visualization behavior if export fails. If the +environment variable is unset and `with_output_dir` is not called, YAML is only +returned to the caller and no file is written. + ### Auto value outputs and fee estimation `Protocol::compute_minimum_output_values` backfills outputs marked with `AUTO_AMOUNT` or `RECOVER_AMOUNT`. `AUTO_AMOUNT` placeholders are bumped up just enough for the downstream transaction to pay its own fee estimate (1 sat/vB plus a 5% buffer), while `RECOVER_AMOUNT` placeholders scoop up any leftover value from the parent subtree so no funds are stranded. diff --git a/src/builder/protocol.rs b/src/builder/protocol.rs index 56465cc..d64e684 100644 --- a/src/builder/protocol.rs +++ b/src/builder/protocol.rs @@ -12,7 +12,10 @@ use storage_backend::storage::{KeyValueStore, Storage}; use crate::{ errors::ProtocolBuilderError, - graph::graph::{GraphOptions, TransactionGraph}, + graph::{ + graph::{GraphOptions, TransactionGraph}, + studio_yaml::StudioExportSettings, + }, scripts::ProtocolScript, types::{ connection::{ConnectionType, InputSpec, OutputSpec}, @@ -23,6 +26,7 @@ use crate::{ }; use super::check_params::{check_empty_connection_name, check_empty_transaction_name}; +use tracing::{info, warn}; #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Protocol { @@ -63,6 +67,15 @@ impl Protocol { Ok(self) } + pub fn add_external_transaction_with_txid( + &mut self, + transaction_name: &str, + txid: Txid, + ) -> Result<&mut Self, ProtocolBuilderError> { + self.get_or_create_transaction_with_txid(transaction_name, txid)?; + Ok(self) + } + pub fn add_unknown_outputs( &mut self, transaction_name: &str, @@ -586,9 +599,25 @@ impl Protocol { } pub fn visualize(&self, options: GraphOptions) -> Result { + // Export alongside visualization when PROTOCOL_BUILDER_EXPORT_DIR is set. + // Never fail visualization on an optional diagnostic export. + let export_settings = StudioExportSettings::default(); + if let Some(output_dir) = export_settings.output_dir.clone() { + match self.export_studio_yaml(export_settings) { + Ok(_) => info!("studio yaml exported under {}", output_dir.display()), + Err(error) => warn!("studio yaml export failed for '{}': {:?}", self.name, error), + } + } Ok(self.graph.visualize(options)?) } + pub fn export_studio_yaml( + &self, + settings: StudioExportSettings, + ) -> Result { + Ok(self.graph.export_studio_yaml(&self.name, settings)?) + } + pub(crate) fn transaction_template() -> Transaction { Transaction { version: transaction::Version::TWO, // Post BIP-68. @@ -618,6 +647,26 @@ impl Protocol { .clone()) } + fn get_or_create_transaction_with_txid( + &mut self, + transaction_name: &str, + txid: Txid, + ) -> Result { + check_empty_transaction_name(transaction_name)?; + + if !self.graph.contains_transaction(transaction_name) { + let transaction = Protocol::transaction_template(); + self.graph + .add_transaction_with_txid(transaction_name, transaction, txid)?; + }; + + Ok(self + .graph + .get_transaction_by_name(transaction_name) + .unwrap() + .clone()) + } + fn get_dependencies( &self, transaction_name: &str, diff --git a/src/errors.rs b/src/errors.rs index 72895cd..f48d951 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -83,6 +83,16 @@ pub enum GraphError { #[error("Output amount is less than dust limit: {0} ")] DustOutput(String), + + #[error("Failed to serialize studio YAML: {0}")] + YamlSerialization(#[from] serde_yaml::Error), + + #[error("Failed to write studio YAML to {path}: {source}")] + StudioYamlIo { + path: std::path::PathBuf, + #[source] + source: std::io::Error, + }, } #[derive(Error, Debug)] diff --git a/src/graph/graph.rs b/src/graph/graph.rs index d181fbc..3339b77 100644 --- a/src/graph/graph.rs +++ b/src/graph/graph.rs @@ -3,7 +3,7 @@ use std::{ vec, }; -use bitcoin::{secp256k1::Message, Amount, Transaction, TxOut, Txid}; +use bitcoin::{secp256k1::Message, Amount, Sequence, Transaction, TxOut, Txid}; use petgraph::{ algo::toposort, graph::{EdgeIndex, NodeIndex}, @@ -15,6 +15,11 @@ use serde::{Deserialize, Serialize}; use crate::{ errors::GraphError, graph::estimate::estimate_min_relay_fee, + graph::studio_yaml::{ + assemble_doc, make_connection_names_unique, map_input, to_yaml, DocBuilder, + StudioConnection, StudioEndpointFrom, StudioEndpointTo, StudioExportSettings, StudioInput, + StudioOutput, StudioTransaction, + }, types::{ input::{InputSignatures, InputType, SighashType, Signature, SpendMode}, output::OutputType, @@ -36,16 +41,23 @@ pub(crate) struct Node { pub(crate) outputs: Vec, pub(crate) inputs: Vec, pub(crate) external: bool, + pub(crate) txid: Option, } impl Node { - pub(crate) fn new(name: &str, transaction: Transaction, external: bool) -> Self { + pub(crate) fn new( + name: &str, + transaction: Transaction, + external: bool, + txid: Option, + ) -> Self { Node { name: name.to_string(), transaction, outputs: vec![], inputs: vec![], external, + txid, } } @@ -54,6 +66,14 @@ impl Node { .get(input_index) .ok_or(GraphError::MissingInputInfo(self.name.clone(), input_index)) } + + pub(crate) fn get_txid(&self) -> Txid { + if let Some(txid) = self.txid { + txid + } else { + self.transaction.compute_txid() + } + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -102,11 +122,12 @@ impl TransactionGraph { } } - pub fn add_transaction( + fn add_transaction_internal( &mut self, name: &str, transaction: Transaction, external: bool, + txid: Option, ) -> Result<(), GraphError> { if name.trim().is_empty() { return Err(GraphError::EmptyTransactionName); @@ -116,13 +137,31 @@ impl TransactionGraph { return Err(GraphError::TransactionAlreadyExists(name.to_string())); } - let node = Node::new(name, transaction, external); + let node = Node::new(name, transaction, external, txid); let node_index = self.graph.add_node(node.clone()); self.node_indexes.insert(name.to_string(), node_index); Ok(()) } + pub fn add_transaction( + &mut self, + name: &str, + transaction: Transaction, + external: bool, + ) -> Result<(), GraphError> { + self.add_transaction_internal(name, transaction, external, None) + } + + pub fn add_transaction_with_txid( + &mut self, + name: &str, + transaction: Transaction, + txid: Txid, + ) -> Result<(), GraphError> { + self.add_transaction_internal(name, transaction, true, Some(txid)) + } + pub fn update_transaction( &mut self, name: &str, @@ -235,7 +274,7 @@ impl TransactionGraph { pub fn get_transaction_by_id(&self, txid: &Txid) -> Result<&Transaction, GraphError> { for node in self.graph.node_weights() { - if node.transaction.compute_txid() == *txid { + if node.get_txid() == *txid { return Ok(&node.transaction); } } @@ -244,7 +283,7 @@ impl TransactionGraph { pub fn get_transaction_name_by_id(&self, txid: Txid) -> Result<&String, GraphError> { for node in self.graph.node_weights() { - if node.transaction.compute_txid() == txid { + if node.get_txid() == txid { return Ok(&node.name); } } @@ -339,7 +378,7 @@ impl TransactionGraph { pub fn get_transaction_ids(&self) -> Vec { self.graph .node_weights() - .map(|node| node.transaction.compute_txid()) + .map(|node| node.get_txid()) .collect() } @@ -517,18 +556,18 @@ impl TransactionGraph { // Collect parents outputs amount before mutably borrowing self let parent_connections = self.find_incoming_edges(node_index); - let total_parents_amount = - parent_connections - .iter() - .try_fold(0u64, |acc, &connection| { - let parent = self.get_from_node(connection)?; - let output_index = self.get_connection(connection)?.output_index as usize; - Ok(acc - + parent.outputs[output_index] - .get_value() - .ok_or(GraphError::AmountTypeValueExpected)? - .to_sat()) - })?; + let total_parents_amount = parent_connections.iter().try_fold( + 0u64, + |acc, &connection| -> Result { + let parent = self.get_from_node(connection)?; + let output_index = self.get_connection(connection)?.output_index as usize; + Ok(acc + + parent.outputs[output_index] + .get_value() + .ok_or(GraphError::AmountTypeValueExpected)? + .to_sat()) + }, + )?; // Collect the transaction outputs amount, excluding the recovering output let total_transaction_amount = recovering_transaction @@ -770,7 +809,7 @@ impl TransactionGraph { from.name, from.name, fee, - last_chars(&from.transaction.compute_txid().to_string(), 8), + last_chars(&from.get_txid().to_string(), 8), inout, )); @@ -805,6 +844,84 @@ impl TransactionGraph { Ok(result) } + pub fn export_studio_yaml( + &self, + protocol_name: &str, + settings: StudioExportSettings, + ) -> Result { + let mut builder = DocBuilder::new(); + let mut transactions = Vec::new(); + let mut connections = Vec::new(); + + for node_index in self.graph.node_indices() { + let from = self.graph.node_weight(node_index).unwrap(); + let txid = from.get_txid().to_string(); + + let outputs: Vec = + from.outputs.iter().map(|o| builder.map_output(o)).collect(); + + let inputs: Vec = from + .inputs + .iter() + .enumerate() + .map(|(i, inp)| { + let seq = from + .transaction + .input + .get(i) + .map(|ti| ti.sequence) + .unwrap_or(Sequence::MAX); + map_input(inp, seq) + }) + .collect(); + + transactions.push(StudioTransaction { + name: from.name.clone(), + external: from.external, + txid: txid.clone(), + external_txid: if from.external { Some(txid) } else { None }, + outputs, + inputs, + }); + + for edge in self.graph.edges(node_index) { + let connection = edge.weight(); + let to = self.graph.node_weight(edge.target()).unwrap(); + connections.push(StudioConnection { + name: connection.name.clone(), + from: StudioEndpointFrom { + tx: from.name.clone(), + output_index: connection.output_index, + }, + to: StudioEndpointTo { + tx: to.name.clone(), + input_index: connection.input_index, + }, + timelock_blocks: None, + }); + } + } + + make_connection_names_unique(&mut connections); + let output_path = settings.output_path(protocol_name); + let doc = assemble_doc(builder, protocol_name, transactions, connections); + let yaml = to_yaml(&doc)?; + + if let Some(path) = output_path { + let directory = path + .parent() + .expect("studio YAML output path always has a parent directory"); + std::fs::create_dir_all(directory).map_err(|source| GraphError::StudioYamlIo { + path: directory.to_path_buf(), + source, + })?; + std::fs::write(&path, &yaml) + .map_err(|source| GraphError::StudioYamlIo { path, source })?; + } + + Ok(yaml) + } + fn get_node_mut(&mut self, name: &str) -> Result<&mut Node, GraphError> { let node_index = self.get_node_index(name)?; let node = self @@ -916,3 +1033,150 @@ fn last_chars(s: &str, n: usize) -> String { .rev() .collect() } + +#[cfg(test)] +mod studio_export_tests { + use crate::{ + builder::{Protocol, ProtocolBuilder}, + graph::studio_yaml::{StudioDoc, StudioExportSettings}, + scripts::{ProtocolScript, SignMode}, + tests::utils::{TemporaryDir, TestContext}, + types::{ + connection::{InputSpec, OutputSpec}, + input::SpendMode, + output::OutputType, + }, + }; + use bitcoin::hashes::Hash; + use bitcoin::ScriptBuf; + + #[test] + fn exports_studio_yaml_with_txid() { + let tc = TestContext::new("studio_export").unwrap(); + let taproot_key = tc + .key_manager() + .derive_keypair(key_manager::key_type::BitcoinKeyType::P2tr, 0) + .unwrap(); + let value = 1000; + let txid = Hash::all_zeros(); + + let leaf = ProtocolScript::new(ScriptBuf::from(vec![0x51]), &taproot_key, SignMode::Single); + let output_type = + OutputType::segwit_unspendable(ScriptBuf::from(vec![0x6a, 0x01, 0x01])).unwrap(); + + let mut protocol = Protocol::new("studio_demo"); + let builder = ProtocolBuilder {}; + builder + .add_external_connection( + &mut protocol, + "ext", + txid, + OutputSpec::Auto(output_type), + "start", + InputSpec::Auto(tc.ecdsa_sighash_type(), SpendMode::Segwit), + ) + .unwrap() + .add_taproot_connection( + &mut protocol, + "protocol", + "start", + value, + &taproot_key, + &[leaf], + &SpendMode::All { + key_path_sign: SignMode::Single, + }, + "next", + &tc.tr_sighash_type(), + ) + .unwrap(); + protocol.build_and_sign(tc.key_manager(), "").unwrap(); + + let yaml = protocol + .export_studio_yaml(StudioExportSettings::default()) + .unwrap(); + + // parses back into our model (guards shape) + let doc: StudioDoc = serde_yaml::from_str(&yaml).unwrap(); + assert_eq!(doc.name, "studio_demo"); + assert!(doc.transactions.iter().any(|t| t.name == "start")); + // every transaction carries a 64-hex txid equal to compute_txid + for t in &doc.transactions { + assert_eq!(t.txid.len(), 64, "txid is 64 hex chars"); + } + let start = doc.transactions.iter().find(|t| t.name == "start").unwrap(); + let expected = protocol + .transaction_by_name("start") + .unwrap() + .compute_txid() + .to_string(); + assert_eq!(start.txid, expected); + assert!(!doc.connections.is_empty()); + } + + #[test] + fn protocol_delegates_to_graph() { + let tc = TestContext::new("studio_delegate").unwrap(); + let key = tc + .key_manager() + .derive_keypair(key_manager::key_type::BitcoinKeyType::P2wpkh, 0) + .unwrap(); + let mut protocol = Protocol::new("d"); + let out = OutputType::segwit_key(1000u64, &key).unwrap(); + let builder = ProtocolBuilder {}; + builder + .add_external_connection( + &mut protocol, + "ext", + bitcoin::hashes::Hash::all_zeros(), + OutputSpec::Auto(out), + "start", + InputSpec::Auto(tc.ecdsa_sighash_type(), SpendMode::Segwit), + ) + .unwrap(); + protocol.build_and_sign(tc.key_manager(), "").unwrap(); + let yaml = protocol + .export_studio_yaml(StudioExportSettings::default()) + .unwrap(); + assert!(yaml.contains("name: d")); + } + + #[test] + fn exports_each_protocol_to_its_own_yaml_file() { + let tc = TestContext::new("studio_file_export").unwrap(); + let key = tc + .key_manager() + .derive_keypair(key_manager::key_type::BitcoinKeyType::P2wpkh, 0) + .unwrap(); + let mut protocol = Protocol::new("file_export_demo"); + let out = OutputType::segwit_key(1000u64, &key).unwrap(); + ProtocolBuilder {} + .add_external_connection( + &mut protocol, + "ext", + bitcoin::hashes::Hash::all_zeros(), + OutputSpec::Auto(out), + "start", + InputSpec::Auto(tc.ecdsa_sighash_type(), SpendMode::Segwit), + ) + .unwrap(); + protocol.build_and_sign(tc.key_manager(), "").unwrap(); + + let export_root = TemporaryDir::new("studio_file_export_output"); + let output_dir = export_root.path("studio-graphs"); + let yaml = protocol + .export_studio_yaml(StudioExportSettings::default().with_output_dir(&output_dir)) + .unwrap(); + + let output_files: Vec<_> = std::fs::read_dir(&output_dir) + .unwrap() + .map(|entry| entry.unwrap().path()) + .collect(); + assert_eq!(output_files.len(), 1); + assert_eq!( + output_files[0].file_name().unwrap(), + "file_export_demo.yaml" + ); + assert_eq!(std::fs::read_to_string(&output_files[0]).unwrap(), yaml); + } +} diff --git a/src/graph/mod.rs b/src/graph/mod.rs index f9e4880..e491c86 100644 --- a/src/graph/mod.rs +++ b/src/graph/mod.rs @@ -1,3 +1,4 @@ pub mod estimate; #[allow(clippy::module_inception)] pub mod graph; +pub mod studio_yaml; diff --git a/src/graph/studio_yaml.rs b/src/graph/studio_yaml.rs new file mode 100644 index 0000000..50db899 --- /dev/null +++ b/src/graph/studio_yaml.rs @@ -0,0 +1,856 @@ +//! Structural export of a built protocol into bitvmx-protocol-studio YAML +//! (`packages/codegen/src/yaml/schema.ts`). Scripts are emitted as synthesized +//! placeholder defs; see +//! docs/superpowers/specs/2026-07-31-studio-yaml-export-design.md +//! (in the rust-bitvmx-client repo). + +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +/// Environment variable used by [`StudioExportSettings::default`] to select +/// the directory where protocol YAML files are written. +pub const PROTOCOL_BUILDER_EXPORT_DIR: &str = "PROTOCOL_BUILDER_EXPORT_DIR"; + +/// Filesystem settings for Studio YAML exports. +#[derive(Debug, Clone)] +pub struct StudioExportSettings { + /// When set, exports are also written to + /// `/.yaml`. + /// + /// Keeping this optional lets callers use `export_studio_yaml` purely as a + /// serializer. Applications can set it directly from their configuration, + /// or use [`PROTOCOL_BUILDER_EXPORT_DIR`] with the default settings. When + /// neither is configured, no file is written. + pub output_dir: Option, +} + +impl StudioExportSettings { + pub fn with_output_dir(mut self, output_dir: impl Into) -> Self { + self.output_dir = Some(output_dir.into()); + self + } + + pub(crate) fn output_path(&self, protocol_name: &str) -> Option { + self.output_dir + .as_deref() + .map(|directory| studio_yaml_path(directory, protocol_name)) + } +} + +pub(crate) fn studio_yaml_path(directory: &Path, protocol_name: &str) -> PathBuf { + directory.join(format!("{protocol_name}.yaml")) +} + +impl Default for StudioExportSettings { + fn default() -> Self { + Self { + output_dir: std::env::var_os(PROTOCOL_BUILDER_EXPORT_DIR).map(PathBuf::from), + } + } +} + +use std::collections::BTreeMap; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct StudioDoc { + pub format_version: u8, + pub name: String, + pub keys: Vec, + pub scripts: Vec, + pub transactions: Vec, + pub connections: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "kind")] +pub(crate) enum StudioKey { + #[serde(rename = "ecdsa", rename_all = "camelCase")] + Ecdsa { name: String, derivation_index: u32 }, + #[serde(rename = "xonly", rename_all = "camelCase")] + Xonly { name: String, derivation_index: u32 }, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct StudioScript { + pub name: String, + pub default_sign_mode: StudioSignMode, + pub source: String, + pub params: Vec, + pub stack_items: Vec, + pub stack_items_overridden: bool, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct StudioScriptParam { + pub name: String, + #[serde(rename = "type")] + pub ty: String, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub(crate) enum StudioSignMode { + Skip, + Single, + Aggregate, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "kind")] +pub(crate) enum StudioStackItem { + #[serde(rename = "schnorrSig", rename_all = "camelCase")] + SchnorrSig { non_default_sighash: bool }, + #[serde(rename = "ecdsaSig")] + EcdsaSig, + #[serde(rename = "winternitzSig")] + WinternitzSig { size: usize }, + #[serde(rename = "lamportSig")] + LamportSig { size: usize }, + #[serde(rename = "raw")] + Raw { size: usize }, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct StudioScriptUse { + pub script_ref: String, + pub sign_mode: StudioSignMode, + pub bindings: BTreeMap, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "kind")] +pub(crate) enum StudioBinding { + #[serde(rename = "key", rename_all = "camelCase")] + Key { key_ref: String }, + #[serde(rename = "hex")] + Hex { value: String }, + #[serde(rename = "number")] + Number { value: i64 }, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(untagged)] +pub(crate) enum StudioAmount { + Sats(u64), + Symbol(String), // "AUTO" | "RECOVER" +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "kind")] +pub(crate) enum StudioOutput { + #[serde(rename = "taproot", rename_all = "camelCase")] + Taproot { + amount: StudioAmount, + internal_key: String, + leaves: Vec, + }, + #[serde(rename = "p2wpkh", rename_all = "camelCase")] + P2wpkh { amount: StudioAmount, key: String }, + #[serde(rename = "p2wsh", rename_all = "camelCase")] + P2wsh { + amount: StudioAmount, + script: StudioScriptUse, + }, + #[serde(rename = "opReturn", rename_all = "camelCase")] + OpReturn { data_hex: String }, + #[serde(rename = "externalUnknown")] + ExternalUnknown, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct StudioTransaction { + pub name: String, + pub external: bool, + pub txid: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub external_txid: Option, + pub outputs: Vec, + pub inputs: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct StudioInput { + pub sighash_type: StudioSighashType, + pub spend_mode: StudioSpendMode, + #[serde(skip_serializing_if = "Option::is_none")] + pub sequence: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct StudioSighashType { + pub class: StudioSighashClass, + pub flag: StudioSighashFlag, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum StudioSighashClass { + Taproot, + Ecdsa, +} + +#[derive(Debug, Serialize, Deserialize)] +pub(crate) enum StudioSighashFlag { + Default, + All, + None, + Single, + AllPlusAnyoneCanPay, + NonePlusAnyoneCanPay, + SinglePlusAnyoneCanPay, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "kind")] +pub(crate) enum StudioSpendMode { + #[serde(rename = "All", rename_all = "camelCase")] + All { key_path_sign: StudioSignMode }, + #[serde(rename = "KeyOnly", rename_all = "camelCase")] + KeyOnly { key_path_sign: StudioSignMode }, + #[serde(rename = "ScriptsOnly")] + ScriptsOnly, + #[serde(rename = "Scripts")] + Scripts { leaves: Vec }, + #[serde(rename = "Script")] + Script { leaf: usize }, + #[serde(rename = "Segwit")] + Segwit, + #[serde(rename = "None")] + None, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct StudioConnection { + pub name: String, + pub from: StudioEndpointFrom, + pub to: StudioEndpointTo, + #[serde(skip_serializing_if = "Option::is_none")] + pub timelock_blocks: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct StudioEndpointFrom { + pub tx: String, + pub output_index: u32, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct StudioEndpointTo { + pub tx: String, + pub input_index: u32, +} + +use std::collections::{HashMap, HashSet}; + +use bitcoin::hex::DisplayHex; +use bitcoin::PublicKey; + +use crate::scripts::{ProtocolScript, SignMode, StackItem}; +use crate::types::output::AmountType; + +pub(crate) fn make_connection_names_unique(connections: &mut [StudioConnection]) { + let reserved_names: HashSet = connections + .iter() + .map(|connection| connection.name.clone()) + .collect(); + let mut occurrences = HashMap::::new(); + let mut generated_names = HashSet::::new(); + + for connection in connections { + let base_name = connection.name.clone(); + let occurrence = occurrences.entry(base_name.clone()).or_insert(0); + *occurrence += 1; + + if *occurrence == 1 { + continue; + } + + let mut suffix = *occurrence; + loop { + let candidate = format!("{base_name}_{suffix}"); + if !reserved_names.contains(&candidate) && generated_names.insert(candidate.clone()) { + connection.name = candidate; + break; + } + suffix += 1; + } + } +} + +impl From for StudioSignMode { + fn from(m: SignMode) -> Self { + match m { + SignMode::Skip => StudioSignMode::Skip, + SignMode::Single => StudioSignMode::Single, + SignMode::Aggregate => StudioSignMode::Aggregate, + } + } +} + +pub(crate) fn map_amount(a: &AmountType) -> StudioAmount { + match a { + AmountType::Value(v) => StudioAmount::Sats(v.to_sat()), + AmountType::Auto => StudioAmount::Symbol("AUTO".to_string()), + AmountType::Recover => StudioAmount::Symbol("RECOVER".to_string()), + // Return/None carry no explicit sats. Amount-bearing placeholders that + // reach here (e.g. a non-op_return SegwitUnspendable mapped to p2wsh) + // serialize amount 0. + AmountType::Return | AmountType::None => StudioAmount::Sats(0), + } +} + +pub(crate) fn map_stack_item(item: &StackItem) -> StudioStackItem { + match item { + StackItem::SchnorrSig { + non_default_sighash, + } => StudioStackItem::SchnorrSig { + non_default_sighash: *non_default_sighash, + }, + StackItem::EcdsaSig { .. } => StudioStackItem::EcdsaSig, + StackItem::WinternitzSig { size } => StudioStackItem::WinternitzSig { size: *size }, + StackItem::LamportSig { size } => StudioStackItem::LamportSig { size: *size }, + StackItem::Raw { size } => StudioStackItem::Raw { size: *size }, + } +} + +/// Accumulates deduped key/script registries while mapping the graph. +pub(crate) struct DocBuilder { + pub keys: Vec, + pub scripts: Vec, + key_names: HashMap, // "x:" | "e:" -> key name + script_names: HashMap, // script hex -> script name +} + +impl DocBuilder { + pub fn new() -> Self { + Self { + keys: Vec::new(), + scripts: Vec::new(), + key_names: HashMap::new(), + script_names: HashMap::new(), + } + } + + pub fn intern_xonly_key(&mut self, pk: &PublicKey) -> String { + let hex = pk.inner.x_only_public_key().0.to_string(); + let map_key = format!("x:{hex}"); + if let Some(name) = self.key_names.get(&map_key) { + return name.clone(); + } + let name = format!("key_{}", self.keys.len() + 1); + self.keys.push(StudioKey::Xonly { + name: name.clone(), + derivation_index: 0, + }); + self.key_names.insert(map_key, name.clone()); + name + } + + pub fn intern_ecdsa_key(&mut self, pk: &PublicKey) -> String { + let hex = pk.to_string(); + let map_key = format!("e:{hex}"); + if let Some(name) = self.key_names.get(&map_key) { + return name.clone(); + } + let name = format!("key_{}", self.keys.len() + 1); + self.keys.push(StudioKey::Ecdsa { + name: name.clone(), + derivation_index: 0, + }); + self.key_names.insert(map_key, name.clone()); + name + } + + fn intern_script_bytes( + &mut self, + script: &bitcoin::ScriptBuf, + sign_mode: StudioSignMode, + stack_items: Vec, + ) -> String { + let hex = script.to_hex_string(); + if let Some(name) = self.script_names.get(&hex) { + return name.clone(); + } + let name = format!("script_{}", self.scripts.len() + 1); + self.scripts.push(StudioScript { + name: name.clone(), + default_sign_mode: sign_mode, + source: format!("# opaque script 0x{hex}"), + params: Vec::new(), + stack_items, + stack_items_overridden: true, + }); + self.script_names.insert(hex, name.clone()); + name + } + + pub fn intern_script(&mut self, ps: &ProtocolScript) -> StudioScriptUse { + let sign_mode: StudioSignMode = ps.sign_mode().into(); + let items = ps.stack_items().iter().map(map_stack_item).collect(); + let name = self.intern_script_bytes(ps.get_script(), sign_mode, items); + StudioScriptUse { + script_ref: name, + sign_mode, + bindings: BTreeMap::new(), + } + } + + pub fn intern_raw_script( + &mut self, + script: &bitcoin::ScriptBuf, + sign_mode: StudioSignMode, + ) -> StudioScriptUse { + let name = self.intern_script_bytes(script, sign_mode, Vec::new()); + StudioScriptUse { + script_ref: name, + sign_mode, + bindings: BTreeMap::new(), + } + } +} + +use crate::types::output::OutputType; + +impl DocBuilder { + pub fn map_output(&mut self, out: &OutputType) -> StudioOutput { + match out { + OutputType::Taproot { + value, + internal_key, + leaves, + .. + } => StudioOutput::Taproot { + amount: map_amount(value), + internal_key: self.intern_xonly_key(internal_key), + leaves: leaves.iter().map(|l| self.intern_script(l)).collect(), + }, + OutputType::SegwitPublicKey { + value, public_key, .. + } => StudioOutput::P2wpkh { + amount: map_amount(value), + key: self.intern_ecdsa_key(public_key), + }, + OutputType::SegwitScript { value, script, .. } => StudioOutput::P2wsh { + amount: map_amount(value), + script: self.intern_script(script), + }, + OutputType::SegwitUnspendable { + value, + script_pubkey, + } => { + if script_pubkey.is_op_return() { + let mut data = Vec::new(); + for ins in script_pubkey.instructions().flatten() { + if let bitcoin::script::Instruction::PushBytes(b) = ins { + data.extend_from_slice(b.as_bytes()); + } + } + StudioOutput::OpReturn { + data_hex: data.to_lower_hex_string(), + } + } else { + // Non-op_return unspendable: best-effort placeholder p2wsh. + StudioOutput::P2wsh { + amount: map_amount(value), + script: self.intern_raw_script(script_pubkey, StudioSignMode::Skip), + } + } + } + OutputType::ExternalUnknown { .. } => StudioOutput::ExternalUnknown, + } + } +} + +pub(crate) fn to_yaml(doc: &StudioDoc) -> Result { + serde_yaml::to_string(doc) +} + +pub(crate) fn assemble_doc( + builder: DocBuilder, + name: &str, + transactions: Vec, + connections: Vec, +) -> StudioDoc { + StudioDoc { + format_version: 1, + name: name.to_string(), + keys: builder.keys, + scripts: builder.scripts, + transactions, + connections, + } +} + +use bitcoin::{EcdsaSighashType, Sequence, TapSighashType}; + +use crate::types::input::{InputType, SighashType, SpendMode}; + +fn map_tap_flag(t: TapSighashType) -> StudioSighashFlag { + match t { + TapSighashType::Default => StudioSighashFlag::Default, + TapSighashType::All => StudioSighashFlag::All, + TapSighashType::None => StudioSighashFlag::None, + TapSighashType::Single => StudioSighashFlag::Single, + TapSighashType::AllPlusAnyoneCanPay => StudioSighashFlag::AllPlusAnyoneCanPay, + TapSighashType::NonePlusAnyoneCanPay => StudioSighashFlag::NonePlusAnyoneCanPay, + TapSighashType::SinglePlusAnyoneCanPay => StudioSighashFlag::SinglePlusAnyoneCanPay, + } +} + +fn map_ecdsa_flag(t: EcdsaSighashType) -> StudioSighashFlag { + match t { + EcdsaSighashType::All => StudioSighashFlag::All, + EcdsaSighashType::None => StudioSighashFlag::None, + EcdsaSighashType::Single => StudioSighashFlag::Single, + EcdsaSighashType::AllPlusAnyoneCanPay => StudioSighashFlag::AllPlusAnyoneCanPay, + EcdsaSighashType::NonePlusAnyoneCanPay => StudioSighashFlag::NonePlusAnyoneCanPay, + EcdsaSighashType::SinglePlusAnyoneCanPay => StudioSighashFlag::SinglePlusAnyoneCanPay, + } +} + +fn map_spend_mode(m: &SpendMode) -> StudioSpendMode { + match m { + SpendMode::All { key_path_sign } => StudioSpendMode::All { + key_path_sign: (*key_path_sign).into(), + }, + SpendMode::KeyOnly { key_path_sign } => StudioSpendMode::KeyOnly { + key_path_sign: (*key_path_sign).into(), + }, + SpendMode::ScriptsOnly => StudioSpendMode::ScriptsOnly, + SpendMode::Scripts { leaves } => StudioSpendMode::Scripts { + leaves: leaves.clone(), + }, + SpendMode::Script { leaf } => StudioSpendMode::Script { leaf: *leaf }, + SpendMode::Segwit => StudioSpendMode::Segwit, + SpendMode::None => StudioSpendMode::None, + } +} + +pub(crate) fn map_input(input: &InputType, sequence: Sequence) -> StudioInput { + let sighash_type = match input.sighash_type() { + SighashType::Taproot(t) => StudioSighashType { + class: StudioSighashClass::Taproot, + flag: map_tap_flag(*t), + }, + SighashType::Ecdsa(e) => StudioSighashType { + class: StudioSighashClass::Ecdsa, + flag: map_ecdsa_flag(*e), + }, + }; + let sequence = if sequence == Sequence::MAX { + None + } else { + Some(sequence.to_consensus_u32()) + }; + StudioInput { + sighash_type, + spend_mode: map_spend_mode(input.spend_mode()), + sequence, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn serializes_taproot_doc_in_studio_shape() { + use std::collections::BTreeMap; + let doc = StudioDoc { + format_version: 1, + name: "demo".into(), + keys: vec![StudioKey::Xonly { + name: "key_1".into(), + derivation_index: 0, + }], + scripts: vec![StudioScript { + name: "script_1".into(), + default_sign_mode: StudioSignMode::Single, + source: "# opaque script 0x00".into(), + params: vec![], + stack_items: vec![StudioStackItem::SchnorrSig { + non_default_sighash: false, + }], + stack_items_overridden: true, + }], + transactions: vec![StudioTransaction { + name: "start".into(), + external: false, + txid: "aa".repeat(32), + external_txid: None, + outputs: vec![StudioOutput::Taproot { + amount: StudioAmount::Sats(1000), + internal_key: "key_1".into(), + leaves: vec![StudioScriptUse { + script_ref: "script_1".into(), + sign_mode: StudioSignMode::Single, + bindings: BTreeMap::new(), + }], + }], + inputs: vec![], + }], + connections: vec![], + }; + let yaml = to_yaml(&doc).unwrap(); + assert!(yaml.contains("formatVersion: 1")); + assert!(!yaml.contains("settings:")); + assert!(yaml.contains("kind: taproot")); + assert!(yaml.contains("internalKey: key_1")); + assert!(yaml.contains("scriptRef: script_1")); + assert!(yaml.contains("nonDefaultSighash: false")); + assert!(yaml.contains("stackItemsOverridden: true")); + assert!(yaml.contains(&format!("txid: {}", "aa".repeat(32)))); + // round-trips back into the model + let back: StudioDoc = serde_yaml::from_str(&yaml).unwrap(); + assert_eq!(back.transactions[0].name, "start"); + } + + #[test] + fn maps_scalars_and_dedups_scripts() { + use crate::scripts::{ProtocolScript, SignMode, StackItem}; + use crate::types::output::AmountType; + use bitcoin::{Amount, ScriptBuf}; + + assert!(matches!( + map_amount(&AmountType::from(1000u64)), + StudioAmount::Sats(1000) + )); + assert!( + matches!(map_amount(&AmountType::Auto), StudioAmount::Symbol(ref s) if s == "AUTO") + ); + assert!( + matches!(map_amount(&AmountType::Recover), StudioAmount::Symbol(ref s) if s == "RECOVER") + ); + + assert!(matches!( + map_stack_item(&StackItem::WinternitzSig { size: 42 }), + StudioStackItem::WinternitzSig { size: 42 } + )); + assert!(matches!( + map_stack_item(&StackItem::SchnorrSig { + non_default_sighash: true + }), + StudioStackItem::SchnorrSig { + non_default_sighash: true + } + )); + assert!(matches!( + map_stack_item(&StackItem::EcdsaSig { + non_default_sighash: true + }), + StudioStackItem::EcdsaSig + )); + assert!(matches!( + map_stack_item(&StackItem::LamportSig { size: 32 }), + StudioStackItem::LamportSig { size: 32 } + )); + let lamport_yaml = + serde_yaml::to_string(&map_stack_item(&StackItem::LamportSig { size: 32 })).unwrap(); + assert!(lamport_yaml.contains("kind: lamportSig")); + assert!(lamport_yaml.contains("size: 32")); + assert!(matches!( + map_stack_item(&StackItem::Raw { size: 5 }), + StudioStackItem::Raw { size: 5 } + )); + + let _ = Amount::from_sat(1); // touch import to keep example self-contained + let mut b = DocBuilder::new(); + let key = bitcoin::PublicKey::from_slice(&[ + 0x02, 0xc6, 0x04, 0x7f, 0x94, 0x41, 0xed, 0x7d, 0x6d, 0x30, 0x45, 0x40, 0x6e, 0x95, + 0xc0, 0x7c, 0xd8, 0x5c, 0x77, 0x8e, 0x4b, 0x8c, 0xef, 0x3c, 0xa7, 0xab, 0xac, 0x09, + 0xb9, 0x5c, 0x70, 0x9e, 0xe5, + ]) + .unwrap(); + let ps = ProtocolScript::new(ScriptBuf::from(vec![0x51]), &key, SignMode::Single); + let u1 = b.intern_script(&ps); + let u2 = b.intern_script(&ps); + assert_eq!( + u1.script_ref, u2.script_ref, + "identical scripts dedup to one def" + ); + assert_eq!(b.scripts.len(), 1); + assert!(matches!(u1.sign_mode, StudioSignMode::Single)); + } + + #[test] + fn maps_all_output_variants() { + use crate::scripts::op_return; + use crate::types::output::OutputType; + use bitcoin::ScriptBuf; + + let key = bitcoin::PublicKey::from_slice(&[ + 0x02, 0xc6, 0x04, 0x7f, 0x94, 0x41, 0xed, 0x7d, 0x6d, 0x30, 0x45, 0x40, 0x6e, 0x95, + 0xc0, 0x7c, 0xd8, 0x5c, 0x77, 0x8e, 0x4b, 0x8c, 0xef, 0x3c, 0xa7, 0xab, 0xac, 0x09, + 0xb9, 0x5c, 0x70, 0x9e, 0xe5, + ]) + .unwrap(); + + let mut b = DocBuilder::new(); + + // p2wpkh + let out = OutputType::segwit_key(1000u64, &key).unwrap(); + assert!(matches!(b.map_output(&out), StudioOutput::P2wpkh { .. })); + + // op_return via segwit_unspendable + let opret = OutputType::segwit_unspendable(op_return(vec![0xab, 0xcd])).unwrap(); + match b.map_output(&opret) { + StudioOutput::OpReturn { data_hex } => assert_eq!(data_hex, "abcd"), + other => panic!("expected opReturn, got {other:?}"), + } + + // external unknown + let ext = OutputType::segwit_unspendable(ScriptBuf::new()).unwrap(); + // empty script is not op_return -> p2wsh placeholder + assert!(matches!(b.map_output(&ext), StudioOutput::P2wsh { .. })); + } + + #[test] + fn maps_taproot_and_segwit_script_outputs() { + use crate::scripts::{ProtocolScript, SignMode}; + use crate::types::output::OutputType; + use bitcoin::{Amount, ScriptBuf}; + + let key = bitcoin::PublicKey::from_slice(&[ + 0x02, 0xc6, 0x04, 0x7f, 0x94, 0x41, 0xed, 0x7d, 0x6d, 0x30, 0x45, 0x40, 0x6e, 0x95, + 0xc0, 0x7c, 0xd8, 0x5c, 0x77, 0x8e, 0x4b, 0x8c, 0xef, 0x3c, 0xa7, 0xab, 0xac, 0x09, + 0xb9, 0x5c, 0x70, 0x9e, 0xe5, + ]) + .unwrap(); + + let mut b = DocBuilder::new(); + + // taproot: interns internal key + leaf script + let leaf = ProtocolScript::new(ScriptBuf::from(vec![0x51]), &key, SignMode::Single); + let taproot_out = OutputType::taproot(Amount::from_sat(2000), &key, &[leaf]).unwrap(); + match b.map_output(&taproot_out) { + StudioOutput::Taproot { + amount, + internal_key, + leaves, + } => { + assert!(matches!(amount, StudioAmount::Sats(2000))); + assert_eq!(internal_key, "key_1"); + assert_eq!(leaves.len(), 1); + assert_eq!(leaves[0].script_ref, "script_1"); + } + other => panic!("expected taproot, got {other:?}"), + } + assert_eq!(b.keys.len(), 1, "internal key interned once"); + assert_eq!(b.scripts.len(), 1, "leaf script interned once"); + + // segwit_script: maps to P2wsh + let ws_script = ProtocolScript::new(ScriptBuf::from(vec![0x52]), &key, SignMode::Single); + let segwit_out = OutputType::segwit_script(Amount::from_sat(3000), &ws_script).unwrap(); + match b.map_output(&segwit_out) { + StudioOutput::P2wsh { amount, script } => { + assert!(matches!(amount, StudioAmount::Sats(3000))); + assert_eq!(script.script_ref, "script_2"); + } + other => panic!("expected p2wsh, got {other:?}"), + } + } + + #[test] + fn external_unknown_output_roundtrips_through_yaml() { + let doc = StudioDoc { + format_version: 1, + name: "ext".into(), + keys: vec![], + scripts: vec![], + transactions: vec![StudioTransaction { + name: "ext_tx".into(), + external: true, + txid: "bb".repeat(32), + external_txid: None, + outputs: vec![StudioOutput::ExternalUnknown], + inputs: vec![], + }], + connections: vec![], + }; + let yaml = to_yaml(&doc).unwrap(); + assert!(yaml.contains("kind: externalUnknown")); + let back: StudioDoc = serde_yaml::from_str(&yaml).unwrap(); + assert!(matches!( + back.transactions[0].outputs[0], + StudioOutput::ExternalUnknown + )); + } + + #[test] + fn studio_yaml_path_uses_only_the_protocol_name() { + let path = studio_yaml_path(Path::new("exports"), "demo"); + assert_eq!(path, Path::new("exports/demo.yaml")); + } + + #[test] + fn connection_names_are_unique_without_claiming_existing_suffixes() { + fn connection(name: &str) -> StudioConnection { + StudioConnection { + name: name.to_string(), + from: StudioEndpointFrom { + tx: "a".to_string(), + output_index: 0, + }, + to: StudioEndpointTo { + tx: "b".to_string(), + input_index: 0, + }, + timelock_blocks: None, + } + } + + let mut connections = vec![ + connection("claim"), + connection("claim"), + connection("claim_2"), + connection("claim"), + connection("claim_2"), + ]; + + make_connection_names_unique(&mut connections); + + let names: Vec<_> = connections + .iter() + .map(|connection| connection.name.as_str()) + .collect(); + assert_eq!( + names, + ["claim", "claim_3", "claim_2", "claim_4", "claim_2_2"] + ); + } + + #[test] + fn maps_input_sighash_spendmode_sequence() { + use crate::scripts::SignMode; + use crate::types::input::{InputType, SighashType, SpendMode}; + use bitcoin::Sequence; + + let input = InputType::new( + &SpendMode::All { + key_path_sign: SignMode::Single, + }, + &SighashType::taproot_all(), + ); + + let mapped = map_input(&input, Sequence::MAX); + assert!(matches!( + mapped.sighash_type.class, + StudioSighashClass::Taproot + )); + assert!(matches!(mapped.sighash_type.flag, StudioSighashFlag::All)); + assert!(matches!(mapped.spend_mode, StudioSpendMode::All { .. })); + assert_eq!(mapped.sequence, None, "MAX sequence is default -> omitted"); + + let with_seq = map_input(&input, Sequence::from_consensus(100)); + assert_eq!(with_seq.sequence, Some(100)); + } +} diff --git a/src/scripts.rs b/src/scripts.rs index 9d2ad3e..9701533 100644 --- a/src/scripts.rs +++ b/src/scripts.rs @@ -283,6 +283,10 @@ impl ProtocolScript { self.items.clone() } + pub fn sign_mode(&self) -> SignMode { + self.sign_mode + } + pub fn skip_signing(&self) -> bool { self.sign_mode == SignMode::Skip } diff --git a/src/tests/graph_test.rs b/src/tests/graph_test.rs index 7a59d0e..ff509ee 100644 --- a/src/tests/graph_test.rs +++ b/src/tests/graph_test.rs @@ -12,7 +12,7 @@ mod test { fn create_node() { let raw_tx = hex!(SOME_TX); let tx: Transaction = Decodable::consensus_decode(&mut raw_tx.as_slice()).unwrap(); - let node = Node::new("test_tx", tx, false); + let node = Node::new("test_tx", tx, false, None); assert_eq!(node.name, "test_tx"); assert_eq!(node.outputs.len(), 0); @@ -106,7 +106,7 @@ mod test { fn test_missing_input_info() { let raw_tx = hex!(SOME_TX); let tx: Transaction = Decodable::consensus_decode(&mut raw_tx.as_slice()).unwrap(); - let node = Node::new("test_tx", tx, false); + let node = Node::new("test_tx", tx, false, None); let result = node.get_input(0); assert!(result.is_err());