Skip to main content

paralock_cli/
devnet.rs

1//! The local devnet started by the npm CLI (`npx paralock dev` / `paralock devnet up`): its `devnet.json`, the keys
2//! this binary keeps for it, the ths faucet and block production.
3//!
4//! Starting and stopping the devnet (ths, anvil, contract deployment) stays in the npm CLI; this module only uses a
5//! running one.
6
7use 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/// `devnet.json`, as written by `paralock devnet up` (the npm CLI). The ths mnemonic it also holds is never read.
21#[derive(Debug, Clone, Deserialize)]
22#[serde(rename_all = "camelCase")]
23pub struct DevnetFile {
24    /// Zcash regtest endpoints.
25    pub zcash: DevnetZcash,
26    /// One entry per EVM chain the devnet runs, by chain name.
27    pub evm: BTreeMap<String, DevnetEvm>,
28    /// The TypeScript maker.
29    pub maker: DevnetAccount,
30    /// The devnet taker.
31    pub taker: DevnetAccount,
32    /// Submits claims for the taker.
33    pub relayer: DevnetRelayer,
34}
35
36/// Zcash endpoints of the devnet.
37#[derive(Debug, Clone, Deserialize)]
38pub struct DevnetZcash {
39    /// Zebra JSON-RPC (`http://127.0.0.1:18232`).
40    pub rpc: String,
41    /// lightwalletd (`http://127.0.0.1:9067`).
42    #[serde(default)]
43    pub lightwalletd: Option<String>,
44    /// The ths dashboard (faucet, mining).
45    pub dashboard: String,
46}
47
48/// One devnet EVM chain.
49#[derive(Debug, Clone, Deserialize)]
50#[serde(rename_all = "camelCase")]
51pub struct DevnetEvm {
52    /// JSON-RPC endpoint.
53    pub rpc: String,
54    /// Chain id.
55    pub chain_id: u64,
56    /// `ParalockHTLC`.
57    pub htlc: String,
58    /// `ParalockAdaptorEscrow`.
59    #[serde(default)]
60    pub adaptor_escrow: Option<String>,
61    /// Tokens by symbol.
62    pub tokens: BTreeMap<String, String>,
63    /// The symbol of the stablecoin makers quote.
64    pub quote_token: String,
65    /// Block the contracts were deployed in.
66    #[serde(default)]
67    pub start_block: Option<u64>,
68}
69
70/// A devnet participant.
71#[derive(Debug, Clone, Deserialize)]
72#[serde(rename_all = "camelCase")]
73pub struct DevnetAccount {
74    /// The maker's URL (makers only).
75    #[serde(default)]
76    pub url: Option<String>,
77    /// Its EVM key (an anvil dev key: public, devnet only).
78    pub evm_key: String,
79}
80
81/// The devnet relayer.
82#[derive(Debug, Clone, Deserialize)]
83#[serde(rename_all = "camelCase")]
84pub struct DevnetRelayer {
85    /// Its EVM key (an anvil dev key).
86    pub evm_key: String,
87}
88
89impl DevnetEvm {
90    /// The quote token's address.
91    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
100/// Reads `<state_dir>/devnet.json`.
101pub 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
116/// Anvil's well-known development keys (mnemonic `test test … junk`), accounts 0 to 9. They are public: never use them
117/// outside a local chain. On the devnet the TypeScript maker uses 0, the taker 1 and the relayer 2.
118pub 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
131/// Reads the private key in `path`, creating a random one (mode 0600) on first use.
132pub 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
149/// Creates `dir` (and its parents) with mode 0700 on Unix.
150pub 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
161/// Writes a new file readable only by its owner (mode 0600 on Unix); fails if it exists.
162pub 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
177/// The regtest P2PKH address of a private key.
178pub 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/// The devnet's ths dashboard: the regtest faucet.
185#[derive(Debug, Clone)]
186pub struct Faucet {
187    url: String,
188    client: reqwest::Client,
189}
190
191/// Most the ths faucet pays per request.
192const FAUCET_MAX_ZAT: u64 = 500_000_000;
193
194impl Faucet {
195    /// The faucet of the dashboard at `url`.
196    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    /// Sends `zat` of regtest ZEC to `address` (in requests of at most 5 ZEC); ths mines them.
206    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        // ths fails transiently while another process mines; the idempotency key makes retries safe.
224        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
247/// Mines regtest blocks only while nobody else does: the TypeScript maker mines one every 3 s, and a second miner
248/// would shorten every other swap's Zcash window in wall-clock time.
249pub struct Miner {
250    node: ZcashRpc,
251    /// The last tip seen and when it last changed.
252    last: Mutex<(u32, Instant)>,
253}
254
255/// How long the tip must stand still before [`Miner`] mines.
256const STALL: Duration = Duration::from_secs(6);
257
258impl Miner {
259    /// A miner on the devnet's Zebra RPC.
260    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    /// Mines one block if the chain has stalled; otherwise waits a little. Returns true if it mined.
266    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}