Skip to main content

zescrow_core/
interface.rs

1//! JSON schemas, I/O utilities, and chain configuration types.
2//!
3//! This module provides configuration loading with environment variable expansion.
4//! JSON templates can reference environment variables using `${VAR_NAME}` syntax,
5//! which are expanded at load time.
6
7#[cfg(feature = "json")]
8use std::borrow::Cow;
9#[cfg(feature = "json")]
10use std::fs::File;
11#[cfg(feature = "json")]
12use std::path::Path;
13
14#[cfg(feature = "json")]
15use anyhow::Context;
16use bincode::{Decode, Encode};
17#[cfg(feature = "json")]
18use serde::de::DeserializeOwned;
19#[cfg(feature = "json")]
20use serde::{Deserialize, Serialize};
21
22use crate::{Asset, EscrowError, Party};
23
24/// Default path to escrow parameters configuration.
25#[cfg(feature = "json")]
26pub const ESCROW_PARAMS_PATH: &str =
27    concat!(env!("CARGO_MANIFEST_DIR"), "/../deploy/escrow_params.json");
28
29/// Default path to on-chain escrow metadata (output from create command).
30#[cfg(feature = "json")]
31pub const ESCROW_METADATA_PATH: &str = concat!(
32    env!("CARGO_MANIFEST_DIR"),
33    "/../deploy/escrow_metadata.json"
34);
35
36/// Default path to escrow conditions.
37#[cfg(feature = "json")]
38pub const ESCROW_CONDITIONS_PATH: &str = concat!(
39    env!("CARGO_MANIFEST_DIR"),
40    "/../deploy/escrow_conditions.json"
41);
42
43/// Expands environment variable references in a string.
44///
45/// Replaces all occurrences of `${VAR_NAME}` with the corresponding
46/// environment variable value. If the variable is not set, it is
47/// replaced with an empty string.
48///
49/// # Examples
50///
51/// ```
52/// # use zescrow_core::interface::expand_env_vars;
53/// // Strings without `${...}` are returned unchanged.
54/// assert_eq!(expand_env_vars("prefix-suffix"), "prefix-suffix");
55///
56/// // Unset variables expand to empty strings.
57/// assert_eq!(expand_env_vars("${ZESCROW_DOC_UNSET_VAR}"), "");
58/// ```
59#[cfg(feature = "json")]
60#[must_use]
61pub fn expand_env_vars(input: &str) -> Cow<'_, str> {
62    expand_with(input, |name| std::env::var(name).ok())
63}
64
65/// Expands `${VAR}` references in `input`, resolving each name through `lookup`.
66///
67/// Names that `lookup` resolves to `None` are replaced with an empty string.
68/// Returns `Cow::Borrowed` when the input contains no `${` and needs no expansion.
69#[cfg(feature = "json")]
70fn expand_with<F>(input: &str, mut lookup: F) -> Cow<'_, str>
71where
72    F: FnMut(&str) -> Option<String>,
73{
74    if !input.contains("${") {
75        return Cow::Borrowed(input);
76    }
77
78    let mut result = String::with_capacity(input.len());
79    let mut remaining = input;
80
81    while let Some(start) = remaining.find("${") {
82        result.push_str(&remaining[..start]);
83
84        let after_start = &remaining[start + 2..];
85        match after_start.find('}') {
86            Some(end) => {
87                let var_name = &after_start[..end];
88                if let Some(value) = lookup(var_name) {
89                    result.push_str(&value);
90                }
91                remaining = &after_start[end + 1..];
92            }
93            None => {
94                result.push_str(&remaining[start..]);
95                remaining = "";
96            }
97        }
98    }
99
100    result.push_str(remaining);
101    Cow::Owned(result)
102}
103
104/// Expands `${VAR}` references for the config-loading path, failing if any
105/// referenced variable is unset.
106///
107/// Unlike [`expand_env_vars`], a missing variable is a hard error here: a
108/// secret-bearing field such as `sender_private_id` silently expanding to an
109/// empty string would surface only as a confusing downstream failure. The
110/// error names every unset variable so the misconfiguration is actionable.
111#[cfg(feature = "json")]
112fn expand_env_checked(input: &str) -> anyhow::Result<String> {
113    let mut missing = Vec::new();
114    let expanded = expand_with(input, |name| match std::env::var(name) {
115        Ok(value) => Some(value),
116        Err(_) => {
117            missing.push(name.to_string());
118            None
119        }
120    })
121    .into_owned();
122
123    if missing.is_empty() {
124        Ok(expanded)
125    } else {
126        anyhow::bail!(
127            "unset environment variable(s) referenced in configuration: {}",
128            missing.join(", ")
129        )
130    }
131}
132
133/// Reads a JSON-encoded file from the given `path` and deserializes into type `T`.
134///
135/// Environment variable references in the format `${VAR_NAME}` are expanded
136/// before parsing. This allows configuration templates to reference secrets
137/// stored in environment variables or `.env` files.
138///
139/// # Errors
140///
141/// Returns an `anyhow::Error` if the file cannot be opened, read, or parsed.
142///
143/// # Examples
144///
145/// ```ignore
146/// # use zescrow_core::interface::load_escrow_data;
147///
148/// #[derive(Deserialize)]
149/// struct MyParams { /* fields matching JSON */ }
150///
151/// // JSON file can contain: { "key": "${MY_SECRET}" }
152/// let _params: MyParams = load_escrow_data("./my_params.json").unwrap();
153/// ```
154#[cfg(feature = "json")]
155pub fn load_escrow_data<P, T>(path: P) -> anyhow::Result<T>
156where
157    P: AsRef<Path>,
158    T: DeserializeOwned,
159{
160    let path = path.as_ref();
161    let content =
162        std::fs::read_to_string(path).with_context(|| format!("loading escrow data: {path:?}"))?;
163    let expanded = expand_env_checked(&content)?;
164    serde_json::from_str(&expanded).with_context(|| format!("parsing JSON from {path:?}"))
165}
166
167/// Writes `data` (serializable) as pretty-printed JSON to the given `path`.
168///
169/// # Errors
170///
171/// Returns an `anyhow::Error` if the file cannot be created or data cannot be serialized.
172///
173/// # Examples
174///
175/// ```ignore
176/// # use zescrow_core::interface::save_escrow_data;
177/// # use serde::Serialize;
178///
179/// #[derive(Serialize)]
180/// struct MyMetadata { /* fields */ }
181///
182/// let metadata = MyMetadata { /* ... */ };
183/// save_escrow_data("./metadata.json", &metadata).unwrap();
184/// ```
185#[cfg(feature = "json")]
186pub fn save_escrow_data<P, T>(path: P, data: &T) -> anyhow::Result<()>
187where
188    P: AsRef<Path>,
189    T: Serialize,
190{
191    let path = path.as_ref();
192    let file = File::create(path).with_context(|| format!("creating file {path:?}"))?;
193    serde_json::to_writer_pretty(file, data)
194        .with_context(|| format!("serializing to JSON to {path:?}"))
195}
196
197/// State of escrow execution in the `client`.
198#[cfg_attr(feature = "json", derive(Serialize, Deserialize))]
199#[derive(Debug, Clone, Copy, Encode, Decode, PartialEq, Eq)]
200pub enum ExecutionState {
201    /// Escrow object created.
202    Initialized,
203
204    /// Funds have been deposited; awaiting release or cancellation.
205    Funded,
206
207    /// Conditions (if any) have been fulfilled;
208    /// funds will be released to the recipient if the proof verifies on-chain.
209    ConditionsMet,
210}
211
212/// Metadata returned from on-chain escrow creation.
213#[cfg_attr(feature = "json", derive(Serialize, Deserialize))]
214#[derive(Debug, Clone, Encode, Decode)]
215pub struct EscrowMetadata {
216    /// The parameters that were specified during escrow creation.
217    pub params: EscrowParams,
218    /// State of escrow execution in the `client`.
219    pub state: ExecutionState,
220    /// Unique identifier for the created escrow.
221    pub escrow_id: Option<u64>,
222}
223
224/// Parameters required to create an escrow on-chain.
225#[cfg_attr(feature = "json", derive(Serialize, Deserialize))]
226#[derive(Debug, Clone, Encode, Decode)]
227pub struct EscrowParams {
228    /// Chain-specific network configuration.
229    pub chain_config: ChainConfig,
230
231    /// Exactly which asset to lock (the native coin or a fungible token).
232    pub asset: Asset,
233
234    /// Who is funding the escrow.
235    pub sender: Party,
236
237    /// Who will receive the funds once conditions pass.
238    pub recipient: Party,
239
240    /// Optional block height or slot after which "release" is allowed.
241    /// Must be `None` or less than `cancel_after` if both are set.
242    pub finish_after: Option<u64>,
243
244    /// Optional block height or slot after which "cancel" is allowed.
245    /// Must be `None` or greater than `finish_after` if both are set.
246    pub cancel_after: Option<u64>,
247
248    /// Denotes whether this escrow is subject to cryptographic conditions.
249    pub has_conditions: bool,
250}
251
252/// Chain-specific network configuration for creating or querying escrows.
253#[cfg_attr(feature = "json", derive(Serialize, Deserialize))]
254#[derive(Debug, Clone, Encode, Decode)]
255pub struct ChainConfig {
256    /// Network identifier.
257    pub chain: Chain,
258    /// JSON-RPC endpoint URL.
259    pub rpc_url: String,
260    /// Sender's private key and/or keypair path.
261    ///
262    /// For Ethereum, a wallet import format (WIF) or hex is expected.
263    /// For Solana, a path to a keypair file (e.g., `~/.config/solana/id.json`).
264    pub sender_private_id: String,
265    /// On-chain escrow program ID (Solana) or smart contract address (Ethereum).
266    pub agent_id: String,
267}
268
269/// Supported blockchain networks.
270#[cfg_attr(feature = "json", derive(Serialize, Deserialize))]
271#[cfg_attr(feature = "json", serde(rename_all = "lowercase"))]
272#[derive(Debug, Copy, Clone, PartialEq, Eq, Encode, Decode)]
273pub enum Chain {
274    /// Ethereum and other EVM-compatible chains.
275    Ethereum,
276    /// Solana
277    Solana,
278}
279
280impl AsRef<str> for Chain {
281    fn as_ref(&self) -> &str {
282        match self {
283            Chain::Ethereum => "ethereum",
284            Chain::Solana => "solana",
285        }
286    }
287}
288
289impl std::str::FromStr for Chain {
290    type Err = EscrowError;
291
292    /// Parses a string ID into a `Chain` enum (case-insensitive).
293    ///
294    /// # Errors
295    ///
296    /// Returns `EscrowError::UnsupportedChain` on unrecognized input.
297    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
298        match s.to_lowercase().as_str() {
299            "ethereum" | "eth" => Ok(Self::Ethereum),
300            "solana" | "sol" => Ok(Self::Solana),
301            _ => Err(EscrowError::UnsupportedChain),
302        }
303    }
304}
305
306#[cfg(all(test, feature = "json"))]
307mod tests {
308    use std::str::FromStr;
309
310    use super::*;
311
312    #[test]
313    fn chain_from_str_ethereum() {
314        assert!(matches!(Chain::from_str("ethereum"), Ok(Chain::Ethereum)));
315        assert!(matches!(Chain::from_str("ETHEREUM"), Ok(Chain::Ethereum)));
316        assert!(matches!(Chain::from_str("eth"), Ok(Chain::Ethereum)));
317        assert!(matches!(Chain::from_str("ETH"), Ok(Chain::Ethereum)));
318    }
319
320    #[test]
321    fn chain_from_str_solana() {
322        assert!(matches!(Chain::from_str("solana"), Ok(Chain::Solana)));
323        assert!(matches!(Chain::from_str("SOLANA"), Ok(Chain::Solana)));
324        assert!(matches!(Chain::from_str("sol"), Ok(Chain::Solana)));
325        assert!(matches!(Chain::from_str("SOL"), Ok(Chain::Solana)));
326    }
327
328    #[test]
329    fn chain_from_str_unsupported() {
330        assert!(matches!(
331            Chain::from_str("bitcoin"),
332            Err(EscrowError::UnsupportedChain)
333        ));
334        assert!(matches!(
335            Chain::from_str(""),
336            Err(EscrowError::UnsupportedChain)
337        ));
338    }
339
340    #[test]
341    fn chain_as_ref() {
342        assert_eq!(Chain::Ethereum.as_ref(), "ethereum");
343        assert_eq!(Chain::Solana.as_ref(), "solana");
344    }
345
346    #[test]
347    fn expand_env_vars_no_vars() {
348        let result = expand_env_vars("no variables here");
349        assert_eq!(result, "no variables here");
350        // Should return Borrowed when no expansion needed
351        assert!(matches!(result, std::borrow::Cow::Borrowed(_)));
352    }
353
354    #[test]
355    fn expand_with_single_var() {
356        let result = expand_with("prefix-${VAR}-suffix", |name| {
357            (name == "VAR").then(|| "hello".to_string())
358        });
359        assert_eq!(result, "prefix-hello-suffix");
360    }
361
362    #[test]
363    fn expand_with_multiple_vars() {
364        let result = expand_with("${A} and ${B}", |name| match name {
365            "A" => Some("alpha".to_string()),
366            "B" => Some("beta".to_string()),
367            _ => None,
368        });
369        assert_eq!(result, "alpha and beta");
370    }
371
372    #[test]
373    fn expand_with_unset_becomes_empty() {
374        let result = expand_with("before-${UNSET}-after", |_| None);
375        assert_eq!(result, "before--after");
376    }
377
378    #[test]
379    fn expand_with_unclosed_brace() {
380        let result = expand_with("prefix-${UNCLOSED", |_| Some("value".to_string()));
381        assert_eq!(result, "prefix-${UNCLOSED");
382    }
383
384    #[test]
385    fn expand_env_checked_passthrough_without_vars() {
386        let result = expand_env_checked("no variables here").unwrap();
387        assert_eq!(result, "no variables here");
388    }
389
390    #[test]
391    fn expand_env_checked_errors_on_unset() {
392        let err = expand_env_checked("key=${ZESCROW_TEST_DEFINITELY_UNSET_VAR_XYZ}").unwrap_err();
393        assert!(
394            err.to_string()
395                .contains("ZESCROW_TEST_DEFINITELY_UNSET_VAR_XYZ")
396        );
397    }
398}