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;
9
10/// Policy files commands
11#[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    /// Generate CIDs for policy files
21    #[command(name = "generate-cids")]
22    GenerateCids(GenerateCidsCommand),
23}
24
25/// Generate CIDs command
26///
27/// Uploads policy files to IPFS via Pinata and generates a policy_cids.json file
28/// containing all CIDs needed for on-chain policy and policy-data deployment.
29///
30/// NOTE: Pinata is the currently supported IPFS hosting backend, but the Newton
31/// protocol itself is IPFS-backend agnostic. Any pinning service that produces
32/// valid IPFS CIDs can be used.
33#[derive(Debug, Parser)]
34pub struct GenerateCidsCommand {
35    /// Directory containing policy files (defaults to policy-files)
36    #[arg(short, long, default_value = "policy-files")]
37    directory: PathBuf,
38
39    /// Entrypoint (e.g., "newton_trading_agent.allow")
40    #[arg(long)]
41    entrypoint: String,
42
43    /// Chain ID for attester resolution from config
44    #[arg(long)]
45    chain_id: u64,
46
47    /// Deployment environment for config resolution (reads newton_prover_config.{env}.json)
48    #[arg(long, default_value = "prod")]
49    env: String,
50
51    /// Optional path to secrets schema JSON file. If provided, uploads to IPFS
52    /// and includes secretsSchemaCid in the output.
53    #[arg(long)]
54    secrets_schema_file: Option<PathBuf>,
55
56    /// Pinata JWT for IPFS uploads (or set PINATA_JWT env var).
57    /// Pinata is one supported IPFS backend; the protocol is backend-agnostic.
58    #[arg(long, env = "PINATA_JWT")]
59    pinata_jwt: Option<String>,
60
61    /// Pinata Gateway URL for constructing IPFS links (or set PINATA_GATEWAY env var)
62    #[arg(long, env = "PINATA_GATEWAY")]
63    pinata_gateway: Option<String>,
64
65    /// Output file path (defaults to policy-files/policy_cids.json)
66    #[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, // Key in the PolicyCids struct
91}
92
93/// Resolve the attester address from newton_prover_config.{env}.json.
94///
95/// Resolution order:
96/// 1. `taskGenerator[0]` in the chain's config section
97/// 2. `task_generator_addr` (legacy key) in the chain's config section
98/// 3. Hardcoded per-chain fallback addresses
99fn 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            // Try taskGenerator[0]
109            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            // Fallback: task_generator_addr
120            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
157/// Check that a required policy file exists in the directory.
158fn 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    /// Upload a file to Pinata IPFS
168    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        // Check if file exists
175        if !file.exists() {
176            eyre::bail!("Error: file not found: {:?}", file);
177        }
178
179        // Read file
180        let file_data = std::fs::read(&file).with_context(|| format!("Failed to read file: {:?}", file))?;
181
182        // Get file name from path
183        let file_name_from_path = file.file_name().and_then(|n| n.to_str()).unwrap_or("file");
184
185        // Create multipart form
186        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        // Upload to Pinata
198        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        // Parse response
214        let response_json: serde_json::Value = response.json().await.context("Failed to parse Pinata response")?;
215
216        // Extract IPFS hash
217        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    /// Display upload results
226    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        // Construct gateway links
230        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    /// Execute the generate-cids command
241    pub async fn execute(self: Box<Self>, _config: NewtonAvsConfig<NewtonCliConfig>) -> Result<()> {
242        // Get Pinata credentials
243        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        // Validate required files exist before starting uploads
252        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        // Resolve attester from config
269        let attester = resolve_attester(self.chain_id, &self.env)?;
270
271        // Define all files to upload in the correct order
272        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        // Add secrets schema if provided
316        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        // Upload each file and collect IPFS hashes
343        for file_upload in files_to_upload {
344            tracing::info!("================================================");
345            tracing::info!("{}", file_upload.header);
346            tracing::info!("================================================");
347
348            // Generate file name with timestamp
349            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        // Build output JSON
368        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        // Write to JSON file
387        let json_content =
388            serde_json::to_string_pretty(&policy_cids).context("failed to serialize policy_cids to JSON")?;
389
390        // Create parent directory if it doesn't exist
391        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    /// Execute the policy-files command
411    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}