1use std::cell::OnceCell;
2use std::fs;
3use std::path::PathBuf;
4use std::str::FromStr;
5
6use alloy_primitives::Address;
7use blueprint_client_tangle::{TangleClient, TangleClientConfig, TangleSettings};
8use blueprint_runner::tangle::config::TangleProtocolSettings;
9use blueprint_testing_utils::anvil::{
10 SeededTangleTestnet, TangleHarness,
11 tangle::{LOCAL_SERVICE_ID, insert_default_operator_key},
12};
13use clap::Args;
14use color_eyre::eyre::{Result, eyre};
15use tempfile::TempDir;
16use url::Url;
17
18use crate::command::run::tangle::RunOpts;
19use crate::workspace::{Network, TangleWorkspace};
20use blueprint_manager::config::SourceType;
21
22#[derive(Args, Debug, Clone, Default)]
34pub struct TangleClientArgs {
35 #[arg(long, value_name = "URL")]
37 pub http_rpc_url: Option<Url>,
38 #[arg(long, value_name = "URL")]
40 pub ws_rpc_url: Option<Url>,
41 #[arg(long)]
43 pub keystore_path: Option<PathBuf>,
44 #[arg(long, value_name = "ADDRESS")]
46 pub tangle_contract: Option<String>,
47 #[arg(long = "staking-contract", value_name = "ADDRESS")]
49 pub staking_contract: Option<String>,
50 #[arg(long, value_name = "ADDRESS")]
52 pub status_registry_contract: Option<String>,
53 #[arg(long, value_name = "NAME")]
55 pub network: Option<String>,
56 #[clap(skip)]
60 #[doc(hidden)]
61 resolved: OnceCell<Resolved>,
62}
63
64#[derive(Debug, Clone)]
66struct Resolved {
67 http_rpc_url: Url,
68 ws_rpc_url: Url,
69 keystore_path: PathBuf,
70 tangle: Address,
71 staking: Address,
72 status: Address,
73}
74
75impl TangleClientArgs {
76 fn resolve(&self) -> Result<&Resolved> {
77 if let Some(r) = self.resolved.get() {
78 return Ok(r);
79 }
80 let r = self.resolve_fresh()?;
81 let _ = self.resolved.set(r);
83 Ok(self.resolved.get().expect("just set"))
84 }
85
86 fn resolve_fresh(&self) -> Result<Resolved> {
87 let ws = TangleWorkspace::discover()?;
88 let net: Option<&Network> = match (&self.network, ws.as_ref()) {
89 (Some(name), Some(ws)) => Some(ws.network(name)?),
90 (None, Some(ws)) => Some(ws.active_network()?),
91 _ => None,
92 };
93
94 let http_rpc_url = self
95 .http_rpc_url
96 .clone()
97 .or_else(|| net.map(|n| n.http_rpc_url.clone()))
98 .unwrap_or_else(|| Url::parse("http://127.0.0.1:8545").expect("literal url"));
99
100 let ws_rpc_url = self
104 .ws_rpc_url
105 .clone()
106 .or_else(|| net.map(|n| n.ws_rpc_url.clone()))
107 .unwrap_or_else(|| Url::parse("ws://127.0.0.1:8545").expect("literal url"));
108
109 let keystore_path = self
110 .keystore_path
111 .clone()
112 .or_else(|| ws.as_ref().and_then(|w| w.defaults.keystore_path.clone()))
113 .unwrap_or_else(|| PathBuf::from("./keystore"));
114
115 let tangle = match (&self.tangle_contract, net) {
116 (Some(s), _) => parse_address(s, "TANGLE_CONTRACT")?,
117 (None, Some(n)) => n.tangle_contract,
118 (None, None) => {
119 return Err(missing_addr_err("tangle_contract", "TANGLE_CONTRACT"));
120 }
121 };
122 let staking = match (&self.staking_contract, net) {
123 (Some(s), _) => parse_address(s, "STAKING_CONTRACT")?,
124 (None, Some(n)) => n.staking_contract,
125 (None, None) => {
126 return Err(missing_addr_err("staking_contract", "STAKING_CONTRACT"));
127 }
128 };
129 let status = match (&self.status_registry_contract, net) {
130 (Some(s), _) => parse_address(s, "STATUS_REGISTRY_CONTRACT")?,
131 (None, Some(n)) => n.status_registry_contract.unwrap_or(Address::ZERO),
132 (None, None) => Address::ZERO,
133 };
134
135 Ok(Resolved {
136 http_rpc_url,
137 ws_rpc_url,
138 keystore_path,
139 tangle,
140 staking,
141 status,
142 })
143 }
144
145 pub fn client_config(
147 &self,
148 blueprint_id: u64,
149 service_id: Option<u64>,
150 ) -> Result<TangleClientConfig> {
151 let r = self.resolve()?;
152 let settings = TangleSettings {
153 blueprint_id,
154 service_id,
155 tangle_contract: r.tangle,
156 staking_contract: r.staking,
157 status_registry_contract: r.status,
158 };
159
160 Ok(TangleClientConfig::new(
161 r.http_rpc_url.clone(),
162 r.ws_rpc_url.clone(),
163 r.keystore_path.display().to_string(),
164 settings,
165 ))
166 }
167
168 pub async fn connect(
170 &self,
171 blueprint_id: u64,
172 service_id: Option<u64>,
173 ) -> Result<TangleClient> {
174 let config = self.client_config(blueprint_id, service_id)?;
175 TangleClient::new(config)
176 .await
177 .map_err(|e| eyre!(e.to_string()))
178 }
179
180 pub fn keystore_path(&self) -> Result<PathBuf> {
182 Ok(self.resolve()?.keystore_path.clone())
183 }
184
185 pub fn http_rpc_url(&self) -> Result<Url> {
187 Ok(self.resolve()?.http_rpc_url.clone())
188 }
189
190 pub fn ws_rpc_url(&self) -> Result<Url> {
192 Ok(self.resolve()?.ws_rpc_url.clone())
193 }
194}
195
196fn missing_addr_err(field: &str, _env_name: &str) -> color_eyre::eyre::Report {
197 eyre!(
198 "missing {field}: pass --{} or define it in `.tangle.toml`. Run `cargo-tangle dev up` to auto-generate a workspace for local development.",
199 field.replace('_', "-")
200 )
201}
202
203#[cfg(test)]
204impl TangleClientArgs {
205 pub fn for_testing(
207 http_rpc_url: Url,
208 ws_rpc_url: Url,
209 keystore_path: PathBuf,
210 tangle_contract: impl Into<String>,
211 staking_contract: impl Into<String>,
212 status_registry_contract: Option<String>,
213 ) -> Self {
214 Self {
215 http_rpc_url: Some(http_rpc_url),
216 ws_rpc_url: Some(ws_rpc_url),
217 keystore_path: Some(keystore_path),
218 tangle_contract: Some(tangle_contract.into()),
219 staking_contract: Some(staking_contract.into()),
220 status_registry_contract,
221 network: None,
222 resolved: OnceCell::new(),
223 }
224 }
225}
226
227pub fn parse_address(value: &str, name: &str) -> Result<Address> {
229 Address::from_str(value).map_err(|_| eyre!("Invalid {name}: {value}"))
230}
231
232#[derive(clap::ValueEnum, Clone, Copy, Debug, Default)]
234pub enum SpawnMethod {
235 #[default]
236 Vm,
237 Native,
238 Container,
239}
240
241impl SpawnMethod {
242 #[must_use]
243 pub fn preferred_source(self) -> SourceType {
244 match self {
245 SpawnMethod::Container => SourceType::Container,
246 SpawnMethod::Vm | SpawnMethod::Native => SourceType::Native,
247 }
248 }
249
250 #[must_use]
251 pub fn use_vm(self) -> bool {
252 matches!(self, SpawnMethod::Vm)
253 }
254}
255
256#[derive(clap::ValueEnum, Clone, Copy, Debug)]
258pub enum PreferredSourceArg {
259 Native,
260 Container,
261 Wasm,
262}
263
264impl From<PreferredSourceArg> for SourceType {
265 fn from(value: PreferredSourceArg) -> Self {
266 match value {
267 PreferredSourceArg::Native => SourceType::Native,
268 PreferredSourceArg::Container => SourceType::Container,
269 PreferredSourceArg::Wasm => SourceType::Wasm,
270 }
271 }
272}
273
274#[derive(Debug)]
276pub struct DevnetStack {
277 harness: SeededTangleTestnet,
278 _temp_dir: TempDir,
279 keystore_dir: PathBuf,
280 data_dir: PathBuf,
281}
282
283impl DevnetStack {
284 pub async fn spawn(include_anvil_logs: bool) -> Result<Self> {
286 let harness = TangleHarness::builder()
287 .include_anvil_logs(include_anvil_logs)
288 .spawn()
289 .await
290 .map_err(|e| eyre!(e.to_string()))?;
291
292 let temp_dir =
293 TempDir::new().map_err(|e| eyre!("failed to create temporary workspace: {e}"))?;
294 let keystore_dir = temp_dir.path().join("keystore");
295 ensure_dir(&keystore_dir)?;
296 let data_dir = temp_dir.path().join("data");
297 ensure_dir(&data_dir)?;
298
299 seed_operator_keystore(&keystore_dir)?;
300
301 Ok(Self {
302 harness,
303 _temp_dir: temp_dir,
304 keystore_dir,
305 data_dir,
306 })
307 }
308
309 #[must_use]
311 pub fn http_rpc_url(&self) -> Url {
312 self.harness.http_endpoint().clone()
313 }
314
315 #[must_use]
317 pub fn ws_rpc_url(&self) -> Url {
318 self.harness.ws_endpoint().clone()
319 }
320
321 #[must_use]
323 pub fn keystore_path(&self) -> String {
324 self.keystore_dir.display().to_string()
325 }
326
327 #[must_use]
329 pub fn data_dir(&self) -> PathBuf {
330 self.data_dir.clone()
331 }
332
333 #[must_use]
335 pub fn default_service_id(&self) -> u64 {
336 LOCAL_SERVICE_ID
337 }
338
339 #[must_use]
341 pub fn tangle_contract(&self) -> Address {
342 self.harness.tangle_contract
343 }
344
345 #[must_use]
346 pub fn staking_contract(&self) -> Address {
347 self.harness.staking_contract
348 }
349
350 #[must_use]
351 pub fn status_registry_contract(&self) -> Address {
352 self.harness.status_registry_contract
353 }
354
355 pub async fn shutdown(self) {
359 drop(self);
360 }
361}
362
363pub fn run_opts_from_stack(
364 stack: &DevnetStack,
365 settings: &TangleProtocolSettings,
366 allow_unchecked_attestations: bool,
367 method: SpawnMethod,
368) -> RunOpts {
369 RunOpts {
370 http_rpc_url: stack.http_rpc_url(),
371 ws_rpc_url: stack.ws_rpc_url(),
372 blueprint_id: settings.blueprint_id,
373 service_id: settings.service_id.or(Some(stack.default_service_id())),
374 tangle_contract: stack.tangle_contract(),
375 staking_contract: stack.staking_contract(),
376 status_registry_contract: stack.status_registry_contract(),
377 keystore_path: stack.keystore_path(),
378 data_dir: Some(stack.data_dir()),
379 allow_unchecked_attestations,
380 registration_mode: false,
381 registration_capture_only: false,
382 preferred_source: method.preferred_source(),
383 use_vm: method.use_vm(),
384 dry_run: false,
385 shutdown_after: None,
386 }
387}
388
389fn ensure_dir(path: &PathBuf) -> Result<()> {
390 if !path.exists() {
391 fs::create_dir_all(path)?;
392 }
393 Ok(())
394}
395
396fn seed_operator_keystore(path: &PathBuf) -> Result<()> {
397 let keystore =
398 blueprint_keystore::Keystore::new(blueprint_keystore::KeystoreConfig::new().fs_root(path))?;
399 insert_default_operator_key(&keystore).map_err(|e| eyre!(e.to_string()))?;
400 Ok(())
401}