1use std::collections::BTreeMap;
8use std::path::{Path, PathBuf};
9use std::sync::Mutex;
10use std::time::{Duration, Instant};
11
12use paralock::{Hex, ZcashNode, ZcashRpc};
13use paralock_core::keys::{KeyPair, PrivateKey};
14use paralock_core::zcash::{Network, TransparentAddress};
15use serde::Deserialize;
16use zeroize::Zeroizing;
17
18use crate::error::{CliError, CliResult};
19
20#[derive(Debug, Clone, Deserialize)]
22#[serde(rename_all = "camelCase")]
23pub struct DevnetFile {
24 pub zcash: DevnetZcash,
26 pub evm: BTreeMap<String, DevnetEvm>,
28 pub maker: DevnetAccount,
30 pub taker: DevnetAccount,
32 pub relayer: DevnetRelayer,
34}
35
36#[derive(Debug, Clone, Deserialize)]
38pub struct DevnetZcash {
39 pub rpc: String,
41 #[serde(default)]
43 pub lightwalletd: Option<String>,
44 pub dashboard: String,
46}
47
48#[derive(Debug, Clone, Deserialize)]
50#[serde(rename_all = "camelCase")]
51pub struct DevnetEvm {
52 pub rpc: String,
54 pub chain_id: u64,
56 pub htlc: String,
58 #[serde(default)]
60 pub adaptor_escrow: Option<String>,
61 pub tokens: BTreeMap<String, String>,
63 pub quote_token: String,
65 #[serde(default)]
67 pub start_block: Option<u64>,
68}
69
70#[derive(Debug, Clone, Deserialize)]
72#[serde(rename_all = "camelCase")]
73pub struct DevnetAccount {
74 #[serde(default)]
76 pub url: Option<String>,
77 pub evm_key: String,
79}
80
81#[derive(Debug, Clone, Deserialize)]
83#[serde(rename_all = "camelCase")]
84pub struct DevnetRelayer {
85 pub evm_key: String,
87}
88
89impl DevnetEvm {
90 pub fn quote_token_address(&self) -> CliResult<Hex> {
92 let address = self
93 .tokens
94 .get(&self.quote_token)
95 .ok_or_else(|| CliError::usage(format!("devnet.json lists no {} token", self.quote_token)))?;
96 Hex::new(address).map_err(|_| CliError::usage(format!("devnet.json: bad {} address", self.quote_token)))
97 }
98}
99
100pub fn read_devnet(state_dir: &Path) -> CliResult<(PathBuf, DevnetFile)> {
102 let path = state_dir.join("devnet.json");
103 let text = Zeroizing::new(std::fs::read_to_string(&path).map_err(|_| {
104 CliError::usage(format!("no devnet found at {}", path.display())).hint(
105 "start one with `npx paralock dev` (the npm CLI runs the devnet; this binary only uses it), or set \
106 PARALOCK_HOME to the directory that holds devnet.json",
107 )
108 })?);
109 let file: DevnetFile = serde_json::from_str(&text).map_err(|e| {
110 CliError::usage(format!("{} is not a devnet.json this version understands: {e}", path.display()))
111 .hint("run `npx paralock devnet up` again")
112 })?;
113 Ok((path, file))
114}
115
116pub const ANVIL_KEYS: [&str; 10] = [
119 "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80",
120 "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d",
121 "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a",
122 "0x7c852118294e51e653712a81e05800f419141751be58f605c371e15141b007a6",
123 "0x47e179ec197488593b187f80a00eb0da91f1b9d0b13f8733639f19c30a34926a",
124 "0x8b3a350cf5c34c9194ca85829a2df0ec3153be0318b5e2d3348e872092edffba",
125 "0x92db14e403b83dfe3df233f83dfa3a0d7096f21ca9b0d6d6b8d88b2b4ec1564e",
126 "0x4bbbf85ce3377467afe5d46f804f221813b2bb87f24d81f60f1fcdbf7cbf4356",
127 "0xdbda1821b80551c9d65939329250298aa3472ba22feea921c0cf5d620ea67b97",
128 "0x2a871d0798f97d79848a013d4936a73bf4cc922c825d33c1cf7073dff6d409c6",
129];
130
131pub fn load_or_create_key(path: &Path) -> CliResult<Zeroizing<String>> {
133 if let Ok(text) = std::fs::read_to_string(path) {
134 let key = Zeroizing::new(text.trim().to_owned());
135 PrivateKey::from_hex(key.strip_prefix("0x").unwrap_or(&key))
136 .map_err(|_| CliError::usage(format!("{} does not hold a private key", path.display())))?;
137 return Ok(key);
138 }
139 if let Some(dir) = path.parent() {
140 create_private_dir(dir)?;
141 }
142 let key = KeyPair::try_random(&mut paralock_core::OsRng)
143 .map_err(|e| CliError::failed(format!("no randomness for a new key: {e}")))?;
144 let hex = Zeroizing::new(paralock_core::encoding::to_hex(&*key.private_key().to_bytes()));
145 write_private_file(path, hex.as_bytes())?;
146 Ok(hex)
147}
148
149pub fn create_private_dir(dir: &Path) -> CliResult<()> {
151 #[cfg(unix)]
152 {
153 use std::os::unix::fs::DirBuilderExt;
154 std::fs::DirBuilder::new().recursive(true).mode(0o700).create(dir)?;
155 }
156 #[cfg(not(unix))]
157 std::fs::create_dir_all(dir)?;
158 Ok(())
159}
160
161pub fn write_private_file(path: &Path, bytes: &[u8]) -> CliResult<()> {
163 use std::io::Write as _;
164 let mut options = std::fs::OpenOptions::new();
165 options.write(true).create_new(true);
166 #[cfg(unix)]
167 {
168 use std::os::unix::fs::OpenOptionsExt;
169 options.mode(0o600);
170 }
171 let mut file = options.open(path)?;
172 file.write_all(bytes)?;
173 file.sync_all()?;
174 Ok(())
175}
176
177pub fn address_of(key: &str) -> CliResult<String> {
179 let key = PrivateKey::from_hex(key.strip_prefix("0x").unwrap_or(key))
180 .map_err(|_| CliError::usage("not a 32-byte secp256k1 private key"))?;
181 Ok(TransparentAddress::p2pkh(&key.public_key(), Network::Regtest).encode())
182}
183
184#[derive(Debug, Clone)]
186pub struct Faucet {
187 url: String,
188 client: reqwest::Client,
189}
190
191const FAUCET_MAX_ZAT: u64 = 500_000_000;
193
194impl Faucet {
195 pub fn new(url: &str) -> CliResult<Self> {
197 let client = reqwest::Client::builder()
198 .user_agent(paralock::DEFAULT_USER_AGENT)
199 .timeout(Duration::from_secs(120))
200 .build()
201 .map_err(|e| CliError::failed(format!("HTTP client: {e}")))?;
202 Ok(Self { url: url.trim_end_matches('/').to_owned(), client })
203 }
204
205 pub async fn pay(&self, address: &str, mut zat: u64) -> CliResult<()> {
207 while zat > 0 {
208 let amount = zat.min(FAUCET_MAX_ZAT);
209 self.request(address, amount).await?;
210 zat -= amount;
211 }
212 Ok(())
213 }
214
215 async fn request(&self, address: &str, amount: u64) -> CliResult<()> {
216 let mut key = [0u8; 8];
217 rand_core::RngCore::fill_bytes(&mut paralock_core::OsRng, &mut key);
218 let body = serde_json::json!({
219 "address": address,
220 "amount_zatoshi": amount,
221 "idempotency_key": paralock_core::encoding::to_hex(&key),
222 });
223 let mut last = String::new();
225 for attempt in 0..8u32 {
226 if attempt > 0 {
227 tokio::time::sleep(Duration::from_millis(750) * attempt).await;
228 }
229 let response = self
230 .client
231 .post(format!("{}/api/v1/faucet/address", self.url))
232 .header("content-type", "application/json")
233 .body(body.to_string())
234 .send()
235 .await;
236 match response {
237 Ok(r) if r.status().is_success() => return Ok(()),
238 Ok(r) => last = format!("HTTP {}: {}", r.status().as_u16(), r.text().await.unwrap_or_default()),
239 Err(e) => last = e.without_url().to_string(),
240 }
241 }
242 Err(CliError::failed(format!("the ths faucet failed: {}", last.trim()))
243 .hint("is the devnet running? `npx paralock devnet status`"))
244 }
245}
246
247pub struct Miner {
250 node: ZcashRpc,
251 last: Mutex<(u32, Instant)>,
253}
254
255const STALL: Duration = Duration::from_secs(6);
257
258impl Miner {
259 pub fn new(rpc: &str) -> CliResult<Self> {
261 let node = ZcashRpc::new(rpc).map_err(CliError::from)?;
262 Ok(Self { node, last: Mutex::new((0, Instant::now())) })
263 }
264
265 pub async fn tick(&self) -> bool {
267 let Ok(tip) = self.node.get_block_count().await else {
268 tokio::time::sleep(Duration::from_secs(1)).await;
269 return false;
270 };
271 let stalled = {
272 let mut last = self.last.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
273 if last.0 != tip {
274 *last = (tip, Instant::now());
275 }
276 last.1.elapsed() >= STALL
277 };
278 if !stalled {
279 tokio::time::sleep(Duration::from_secs(1)).await;
280 return false;
281 }
282 let mined = self.node.generate(1).await.is_ok();
283 if mined {
284 let mut last = self.last.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
285 *last = (tip.saturating_add(1), Instant::now());
286 }
287 mined
288 }
289}