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