Skip to main content

boundless_market/storage/providers/
pinata.rs

1// Copyright 2026 Boundless Foundation, Inc.
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Provider implementation for uploading programs and inputs to IPFS via Pinata.
16//!
17//! This is an **upload-only** provider. Downloads from the resulting HTTPS URLs
18//! should be handled by the HTTP downloader.
19
20use crate::storage::{StorageError, StorageUploader, StorageUploaderConfig, StorageUploaderType};
21use anyhow::anyhow;
22use async_trait::async_trait;
23use reqwest::{
24    multipart::{Form, Part},
25    Client,
26};
27use url::Url;
28
29const DEFAULT_PINATA_API_URL: &str = "https://uploads.pinata.cloud";
30const DEFAULT_GATEWAY_URL: &str = "https://gateway.pinata.cloud";
31
32/// Pinata storage uploader for uploading to IPFS.
33///
34/// This provider uploads files to IPFS via Pinata's API and returns
35/// HTTPS gateway URLs for accessing the uploaded content.
36///
37/// # Note
38///
39/// This is an upload-only provider. The returned URLs are HTTPS URLs
40/// that should be downloaded using the HTTP downloader.
41#[derive(Clone, Debug)]
42pub struct PinataStorageUploader {
43    client: Client,
44    pinata_jwt: String,
45    pinata_api_url: Url,
46    ipfs_gateway_url: Url,
47}
48
49impl PinataStorageUploader {
50    /// Creates a new Pinata storage uploader from configuration.
51    pub fn from_config(config: &StorageUploaderConfig) -> Result<Self, StorageError> {
52        assert_eq!(config.storage_uploader, StorageUploaderType::Pinata);
53
54        let jwt =
55            config.pinata_jwt.clone().ok_or_else(|| StorageError::MissingConfig("pinata_jwt"))?;
56
57        let api_url = config
58            .pinata_api_url
59            .clone()
60            .unwrap_or_else(|| Url::parse(DEFAULT_PINATA_API_URL).unwrap());
61
62        let gateway_url = config
63            .ipfs_gateway_url
64            .clone()
65            .unwrap_or_else(|| Url::parse(DEFAULT_GATEWAY_URL).unwrap());
66
67        Ok(Self {
68            client: Client::new(),
69            pinata_jwt: jwt,
70            pinata_api_url: api_url,
71            ipfs_gateway_url: gateway_url,
72        })
73    }
74
75    /// Creates a new Pinata storage uploader with explicit parameters.
76    pub fn new(jwt: String, api_url: Url, gateway_url: Url) -> Self {
77        Self {
78            client: Client::new(),
79            pinata_jwt: jwt,
80            pinata_api_url: api_url,
81            ipfs_gateway_url: gateway_url,
82        }
83    }
84
85    /// Upload data to Pinata and return an IPFS gateway URL.
86    async fn upload(&self, data: &[u8], filename: &str) -> Result<Url, StorageError> {
87        let url = self.pinata_api_url.join("/v3/files")?;
88
89        let form = Form::new()
90            .part(
91                "file",
92                Part::bytes(data.to_vec())
93                    .mime_str("application/octet-stream")
94                    .map_err(StorageError::http)?
95                    .file_name(filename.to_string()),
96            )
97            .part("network", Part::text("public"));
98
99        let request = self
100            .client
101            .post(url)
102            .header("Authorization", format!("Bearer {}", self.pinata_jwt))
103            .multipart(form)
104            .build()
105            .map_err(StorageError::http)?;
106
107        tracing::debug!("Sending Pinata upload request: {}", request.url());
108
109        let response = self.client.execute(request).await.map_err(StorageError::http)?;
110
111        tracing::debug!("Pinata response status: {}", response.status());
112
113        let response = response.error_for_status().map_err(StorageError::http)?;
114
115        let json_value: serde_json::Value = response.json().await.map_err(StorageError::http)?;
116
117        let ipfs_hash = json_value
118            .as_object()
119            .ok_or_else(|| StorageError::Other(anyhow!("response is not a JSON object")))?
120            .get("data")
121            .ok_or_else(|| StorageError::Other(anyhow!("response missing 'data' field")))?
122            .get("cid")
123            .ok_or_else(|| StorageError::Other(anyhow!("response missing 'data.cid' field")))?
124            .as_str()
125            .ok_or_else(|| StorageError::Other(anyhow!("invalid IPFS hash type")))?;
126
127        let data_url = self.ipfs_gateway_url.join(&format!("ipfs/{ipfs_hash}"))?;
128        Ok(data_url)
129    }
130}
131
132#[async_trait]
133impl StorageUploader for PinataStorageUploader {
134    async fn upload_bytes(&self, data: &[u8], key: &str) -> Result<Url, StorageError> {
135        self.upload(data, key).await
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142    use crate::storage::{HttpDownloader, StorageDownloader, StorageUploader};
143    use std::env;
144
145    #[tokio::test]
146    #[ignore = "requires PINATA_JWT credentials"]
147    async fn test_pinata_roundtrip() {
148        let jwt = env::var("PINATA_JWT").expect("PINATA_JWT missing");
149        let api_url = Url::parse(DEFAULT_PINATA_API_URL).unwrap();
150        let gateway_url = Url::parse(DEFAULT_GATEWAY_URL).unwrap();
151        let uploader = PinataStorageUploader::new(jwt, api_url, gateway_url);
152
153        let test_data = b"pinata integration test data";
154        let url = uploader.upload_input(test_data).await.expect("upload failed");
155
156        assert!(
157            url.scheme() == "https" || url.scheme() == "http",
158            "expected HTTPS gateway URL, got {}",
159            url.scheme()
160        );
161
162        let downloader = HttpDownloader::default();
163        let downloaded = downloader.download_url(url).await.expect("download failed");
164
165        assert_eq!(downloaded, test_data);
166    }
167}