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