Skip to main content

newton_cli/commands/
policy_files.rs

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/// Policy files commands
12#[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    /// Generate CIDs for policy files
22    #[command(name = "generate-cids")]
23    GenerateCids(GenerateCidsCommand),
24}
25
26/// Generate CIDs command
27///
28/// Uploads policy files to IPFS via Pinata and generates a policy_cids.json file
29/// containing all CIDs needed for on-chain policy and policy-data deployment.
30///
31/// NOTE: Pinata is the currently supported IPFS hosting backend, but the Newton
32/// protocol itself is IPFS-backend agnostic. Any pinning service that produces
33/// valid IPFS CIDs can be used.
34#[derive(Debug, Parser)]
35pub struct GenerateCidsCommand {
36    /// Directory containing policy files (defaults to policy-files)
37    #[arg(short, long, default_value = "policy-files")]
38    directory: PathBuf,
39
40    /// Entrypoint (e.g., "newton_trading_agent.allow")
41    #[arg(long)]
42    entrypoint: String,
43
44    /// Chain ID for attester resolution from config
45    #[arg(long)]
46    chain_id: u64,
47
48    /// Deployment environment for config resolution (reads newton_prover_config.{env}.json)
49    #[arg(long, default_value = "prod")]
50    env: String,
51
52    /// Optional path to secrets schema JSON file. If provided, uploads to IPFS
53    /// and includes secretsSchemaCid in the output.
54    #[arg(long)]
55    secrets_schema_file: Option<PathBuf>,
56
57    /// Pinata JWT for IPFS uploads (or set PINATA_JWT env var).
58    /// Pinata is one supported IPFS backend; the protocol is backend-agnostic.
59    #[arg(long, env = "PINATA_JWT")]
60    pinata_jwt: Option<String>,
61
62    /// Pinata Gateway URL for constructing IPFS links (or set PINATA_GATEWAY env var)
63    #[arg(long, env = "PINATA_GATEWAY")]
64    pinata_gateway: Option<String>,
65
66    /// Output file path (defaults to policy-files/policy_cids.json)
67    #[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, // Key in the PolicyCids struct
92}
93
94/// Resolve the attester address from newton_prover_config.{env}.json.
95///
96/// Resolution order:
97/// 1. `taskGenerator[0]` in the chain's config section
98/// 2. `task_generator_addr` (legacy key) in the chain's config section
99/// 3. Hardcoded per-chain fallback addresses
100fn 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            // Try taskGenerator[0]
114            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            // Fallback: task_generator_addr
125            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    // Default hardcoded fallback if we cannot read `newton_prover_config.{env}.json`
147    // and don't have a hardcoded `taskGenerator[0]` for this (env, chain_id).
148    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
171/// Check that a required policy file exists in the directory.
172fn 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    /// Upload a file to Pinata IPFS
182    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        // Check if file exists
189        if !file.exists() {
190            eyre::bail!("Error: file not found: {:?}", file);
191        }
192
193        // Read file
194        let file_data = std::fs::read(&file).with_context(|| format!("Failed to read file: {:?}", file))?;
195
196        // Get file name from path
197        let file_name_from_path = file.file_name().and_then(|n| n.to_str()).unwrap_or("file");
198
199        // Create multipart form
200        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        // Upload to Pinata
212        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        // Parse response
228        let response_json: serde_json::Value = response.json().await.context("Failed to parse Pinata response")?;
229
230        // Extract IPFS hash
231        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    /// Display upload results
240    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        // Construct gateway links
244        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    /// Execute the generate-cids command
255    pub async fn execute(self: Box<Self>, _config: NewtonAvsConfig<NewtonCliConfig>) -> Result<()> {
256        // Get Pinata credentials
257        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        // Validate required files exist before starting uploads
266        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        // Resolve attester from config
283        let attester = resolve_attester(self.chain_id, &self.env)?;
284
285        // Define all files to upload in the correct order
286        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        // Add secrets schema if provided
330        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        // Upload each file and collect IPFS hashes
357        for file_upload in files_to_upload {
358            tracing::info!("================================================");
359            tracing::info!("{}", file_upload.header);
360            tracing::info!("================================================");
361
362            // Generate file name with timestamp
363            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        // Build output JSON
382        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        // Write to JSON file
401        let json_content =
402            serde_json::to_string_pretty(&policy_cids).context("failed to serialize policy_cids to JSON")?;
403
404        // Create parent directory if it doesn't exist
405        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    /// Execute the policy-files command
425    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}