1use clap::{Parser, Subcommand};
2use eyre::{Context, Result};
3use newton_prover_core::config::NewtonAvsConfig;
4use serde::{Deserialize, Serialize};
5use std::path::{Path, PathBuf};
6use tracing;
7
8use crate::config::NewtonCliConfig;
9use crate::task_generator;
10
11#[derive(Debug, Parser)]
13#[command(name = "policy-files")]
14pub struct PolicyFilesCommand {
15 #[command(subcommand)]
16 pub subcommand: PolicyFilesSubcommand,
17}
18
19#[derive(Debug, Subcommand)]
20pub enum PolicyFilesSubcommand {
21 #[command(name = "generate-cids")]
23 GenerateCids(GenerateCidsCommand),
24}
25
26#[derive(Debug, Parser)]
35pub struct GenerateCidsCommand {
36 #[arg(short, long, default_value = "policy-files")]
38 directory: PathBuf,
39
40 #[arg(long)]
42 entrypoint: String,
43
44 #[arg(long)]
46 chain_id: u64,
47
48 #[arg(long, default_value = "prod")]
50 env: String,
51
52 #[arg(long)]
55 secrets_schema_file: Option<PathBuf>,
56
57 #[arg(long, env = "PINATA_JWT")]
60 pinata_jwt: Option<String>,
61
62 #[arg(long, env = "PINATA_GATEWAY")]
64 pinata_gateway: Option<String>,
65
66 #[arg(short, long, default_value = "policy-files/policy_cids.json")]
68 output: PathBuf,
69}
70
71#[derive(Debug, Serialize, Deserialize)]
72#[allow(non_snake_case)]
73struct PolicyCids {
74 wasmCid: String,
75 policyCid: String,
76 schemaCid: String,
77 attester: String,
78 entrypoint: String,
79 policyMetadataCid: String,
80 policyDataMetadataCid: String,
81 secretsSchemaCid: String,
82}
83
84#[derive(Debug)]
85struct FileUpload {
86 name: String,
87 path: PathBuf,
88 mime_type: String,
89 default_name_prefix: String,
90 header: String,
91 json_key: String, }
93
94fn resolve_attester(chain_id: u64, env: &str) -> Result<String> {
101 let normalized_env = env.trim().to_lowercase();
102 let config_path = format!(
103 "contracts/newton_prover_config.{}.json",
104 normalized_env.as_str()
105 );
106 let chain_key = chain_id.to_string();
107
108 if let Ok(content) = std::fs::read_to_string(&config_path) {
109 let config: serde_json::Value =
110 serde_json::from_str(&content).with_context(|| format!("failed to parse {}", config_path))?;
111
112 if let Some(chain_config) = config.get(&chain_key) {
113 if let Some(addr) = chain_config
115 .get("taskGenerator")
116 .and_then(|v| v.as_array())
117 .and_then(|arr| arr.first())
118 .and_then(|v| v.as_str())
119 {
120 tracing::info!("Resolved attester from {}: taskGenerator[0] = {}", config_path, addr);
121 return Ok(addr.to_string());
122 }
123
124 if let Some(addr) = chain_config.get("task_generator_addr").and_then(|v| v.as_str()) {
126 tracing::info!("Resolved attester from {}: task_generator_addr = {}", config_path, addr);
127 return Ok(addr.to_string());
128 }
129
130 tracing::warn!(
131 "Config {} has chain {} but no taskGenerator or task_generator_addr, using fallback",
132 config_path,
133 chain_key
134 );
135 } else {
136 tracing::warn!(
137 "Config {} has no entry for chain {}, using fallback",
138 config_path,
139 chain_key
140 );
141 }
142 } else {
143 tracing::warn!("Config file {} not found, using fallback attester", config_path);
144 }
145
146 let default_fallback = match chain_id {
149 31337 => "0x70997970C51812dc3A010C7d01b50e0d17dc79C8",
150 11155111 | 17000 => "0xD45062003a4626a532F30A4596aB253c45AE0647",
151 1 => "0x4883282094755C01cd0d15dFE74753c9E189d194",
152 _ => {
153 return Err(eyre::eyre!(
154 "no attester found in config and no hardcoded fallback for chain {}",
155 chain_id
156 ));
157 }
158 };
159
160 let fallback = task_generator::task_generator_0(normalized_env.as_str(), chain_id)
161 .unwrap_or(default_fallback);
162 tracing::info!(
163 env = %normalized_env,
164 chain_id = %chain_id,
165 attester = %fallback,
166 "Using hardcoded taskGenerator[0] fallback attester"
167 );
168 Ok(fallback.to_string())
169}
170
171fn require_file(dir: &Path, filename: &str) -> Result<()> {
173 let path = dir.join(filename);
174 if !path.exists() {
175 eyre::bail!("{} not found in {:?}", filename, dir);
176 }
177 Ok(())
178}
179
180impl GenerateCidsCommand {
181 pub async fn upload_file_to_pinata(
183 file: PathBuf,
184 jwt: String,
185 file_name: String,
186 mime_type: &str,
187 ) -> Result<String> {
188 if !file.exists() {
190 eyre::bail!("Error: file not found: {:?}", file);
191 }
192
193 let file_data = std::fs::read(&file).with_context(|| format!("Failed to read file: {:?}", file))?;
195
196 let file_name_from_path = file.file_name().and_then(|n| n.to_str()).unwrap_or("file");
198
199 let form = reqwest::multipart::Form::new()
201 .text("pinataOptions", r#"{"cidVersion":1}"#)
202 .text("pinataMetadata", format!(r#"{{"name":"{}"}}"#, file_name))
203 .part(
204 "file",
205 reqwest::multipart::Part::bytes(file_data)
206 .file_name(file_name_from_path.to_string())
207 .mime_str(mime_type)
208 .unwrap(),
209 );
210
211 let client = reqwest::Client::new();
213 let response = client
214 .post("https://api.pinata.cloud/pinning/pinFileToIPFS")
215 .header("Authorization", format!("Bearer {}", jwt))
216 .multipart(form)
217 .send()
218 .await
219 .context("Failed to send request to Pinata")?;
220
221 if !response.status().is_success() {
222 let status = response.status();
223 let error_text = response.text().await.unwrap_or_default();
224 eyre::bail!("Pinata upload failed with status {}: {}", status, error_text);
225 }
226
227 let response_json: serde_json::Value = response.json().await.context("Failed to parse Pinata response")?;
229
230 let ipfs_hash = response_json
232 .get("IpfsHash")
233 .and_then(|v| v.as_str())
234 .ok_or_else(|| eyre::eyre!("IPFS hash not found in response"))?;
235
236 Ok(ipfs_hash.to_string())
237 }
238
239 pub fn display_upload_results(ipfs_hash: &str, gateway: &str) {
241 tracing::info!("\n=== IPFS Upload Results ===");
242 tracing::info!("IPFS Hash: {}", ipfs_hash);
243 let gateway_link = if gateway.ends_with('/') {
245 format!("{}{}", gateway, ipfs_hash)
246 } else {
247 format!("{}/{}", gateway, ipfs_hash)
248 };
249
250 tracing::info!(gateway_link = %gateway_link, "Direct IPFS Link");
251 tracing::info!("Public IPFS Link: https://ipfs.io/ipfs/{}", ipfs_hash);
252 }
253
254 pub async fn execute(self: Box<Self>, _config: NewtonAvsConfig<NewtonCliConfig>) -> Result<()> {
256 let jwt = self
258 .pinata_jwt
259 .ok_or_else(|| eyre::eyre!("Pinata JWT is required. Set PINATA_JWT env var or use --pinata-jwt"))?;
260
261 let gateway = self.pinata_gateway.ok_or_else(|| {
262 eyre::eyre!("Pinata Gateway is required. Set PINATA_GATEWAY env var or use --pinata-gateway")
263 })?;
264
265 for filename in &[
267 "policy.wasm",
268 "policy.rego",
269 "params_schema.json",
270 "policy_metadata.json",
271 "policy_data_metadata.json",
272 ] {
273 require_file(&self.directory, filename)?;
274 }
275
276 if let Some(ref secrets_path) = self.secrets_schema_file {
277 if !secrets_path.exists() {
278 eyre::bail!("secrets schema file not found: {:?}", secrets_path);
279 }
280 }
281
282 let attester = resolve_attester(self.chain_id, &self.env)?;
284
285 let mut files_to_upload = vec![
287 FileUpload {
288 name: "policy.wasm".to_string(),
289 path: self.directory.join("policy.wasm"),
290 mime_type: "application/wasm".to_string(),
291 default_name_prefix: "newton-policy-wasm".to_string(),
292 header: "============ Upload policy.wasm ================".to_string(),
293 json_key: "wasmCid".to_string(),
294 },
295 FileUpload {
296 name: "policy.rego".to_string(),
297 path: self.directory.join("policy.rego"),
298 mime_type: "text/plain".to_string(),
299 default_name_prefix: "newton-policy".to_string(),
300 header: "============== Upload policy.rego ==============".to_string(),
301 json_key: "policyCid".to_string(),
302 },
303 FileUpload {
304 name: "params_schema.json".to_string(),
305 path: self.directory.join("params_schema.json"),
306 mime_type: "application/json".to_string(),
307 default_name_prefix: "newton-policy-schema".to_string(),
308 header: "========== Upload params_schema.json ===========".to_string(),
309 json_key: "schemaCid".to_string(),
310 },
311 FileUpload {
312 name: "policy_metadata.json".to_string(),
313 path: self.directory.join("policy_metadata.json"),
314 mime_type: "application/json".to_string(),
315 default_name_prefix: "newton-policy-metadata".to_string(),
316 header: "========== Upload policy_metadata.json =========".to_string(),
317 json_key: "policyMetadataCid".to_string(),
318 },
319 FileUpload {
320 name: "policy_data_metadata.json".to_string(),
321 path: self.directory.join("policy_data_metadata.json"),
322 mime_type: "application/json".to_string(),
323 default_name_prefix: "newton-policy-data-metadata".to_string(),
324 header: "======== Upload policy_data_metadata.json ======".to_string(),
325 json_key: "policyDataMetadataCid".to_string(),
326 },
327 ];
328
329 if let Some(ref secrets_path) = self.secrets_schema_file {
331 files_to_upload.push(FileUpload {
332 name: secrets_path
333 .file_name()
334 .and_then(|n| n.to_str())
335 .unwrap_or("secrets_schema.json")
336 .to_string(),
337 path: secrets_path.clone(),
338 mime_type: "application/json".to_string(),
339 default_name_prefix: "newton-secrets-schema".to_string(),
340 header: "========== Upload secrets_schema.json ==========".to_string(),
341 json_key: "secretsSchemaCid".to_string(),
342 });
343 }
344
345 tracing::info!("================================================");
346 tracing::info!("========== Uploading All Policy Files ==========");
347 tracing::info!("================================================");
348 tracing::info!("Directory: {:?}", self.directory);
349 tracing::info!("Chain ID: {}", self.chain_id);
350 tracing::info!("Env: {}", self.env);
351 tracing::info!("Attester: {}", attester);
352 tracing::info!("");
353
354 let mut ipfs_hashes: std::collections::HashMap<String, String> = std::collections::HashMap::new();
355
356 for file_upload in files_to_upload {
358 tracing::info!("================================================");
359 tracing::info!("{}", file_upload.header);
360 tracing::info!("================================================");
361
362 let file_name = format!(
364 "{}-{}",
365 file_upload.default_name_prefix,
366 chrono::Utc::now().format("%Y%m%d-%H%M%S")
367 );
368
369 tracing::info!(path = %file_upload.path.display(), "Uploading to Pinata IPFS");
370
371 let ipfs_hash =
372 Self::upload_file_to_pinata(file_upload.path.clone(), jwt.clone(), file_name, &file_upload.mime_type)
373 .await
374 .with_context(|| format!("failed to upload {}", file_upload.name))?;
375
376 Self::display_upload_results(&ipfs_hash, &gateway);
377 ipfs_hashes.insert(file_upload.json_key.clone(), ipfs_hash);
378 tracing::info!("");
379 }
380
381 let get_cid = |key: &str| -> Result<String> {
383 ipfs_hashes
384 .get(key)
385 .cloned()
386 .ok_or_else(|| eyre::eyre!("missing {}", key))
387 };
388
389 let policy_cids = PolicyCids {
390 wasmCid: get_cid("wasmCid")?,
391 policyCid: get_cid("policyCid")?,
392 schemaCid: get_cid("schemaCid")?,
393 attester,
394 entrypoint: self.entrypoint.clone(),
395 policyMetadataCid: get_cid("policyMetadataCid")?,
396 policyDataMetadataCid: get_cid("policyDataMetadataCid")?,
397 secretsSchemaCid: ipfs_hashes.get("secretsSchemaCid").cloned().unwrap_or_default(),
398 };
399
400 let json_content =
402 serde_json::to_string_pretty(&policy_cids).context("failed to serialize policy_cids to JSON")?;
403
404 if let Some(parent) = self.output.parent() {
406 std::fs::create_dir_all(parent).with_context(|| format!("failed to create directory: {:?}", parent))?;
407 }
408
409 std::fs::write(&self.output, &json_content)
410 .with_context(|| format!("failed to write to file: {:?}", self.output))?;
411
412 tracing::info!("================================================");
413 tracing::info!("========== Created policy_cids.json ============");
414 tracing::info!("================================================");
415 tracing::info!("Output file: {:?}", self.output);
416 tracing::info!("");
417 tracing::info!("{}", json_content);
418
419 Ok(())
420 }
421}
422
423impl PolicyFilesCommand {
424 pub async fn execute(self: Box<Self>, config: NewtonAvsConfig<NewtonCliConfig>) -> eyre::Result<()> {
426 match self.subcommand {
427 PolicyFilesSubcommand::GenerateCids(command) => {
428 Box::new(command).execute(config).await?;
429 }
430 }
431 Ok(())
432 }
433}