Skip to content

Commit fb17048

Browse files
authored
feat: upload non encrypted result (#16)
1 parent 1b53250 commit fb17048

File tree

10 files changed

+1829
-11
lines changed

10 files changed

+1829
-11
lines changed

Cargo.lock

Lines changed: 330 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,11 @@ serde_json = "1.0.140"
1313
sha256 = "1.6.0"
1414
sha3 = "0.10.8"
1515
thiserror = "2.0.12"
16+
walkdir = "2.5.0"
17+
zip = "4.0.0"
1618

1719
[dev-dependencies]
20+
mockall = "0.13.1"
1821
temp-env = "0.3.6"
1922
tempfile = "3.20.0"
2023
tokio = "1.45.0"

src/api.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1+
pub mod result_proxy_api_client;
12
pub mod worker_api;

src/api/result_proxy_api_client.rs

Lines changed: 321 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,321 @@
1+
use reqwest::blocking::Client;
2+
use serde::{Deserialize, Serialize};
3+
4+
const EMPTY_HEX_STRING_32: &str =
5+
"0x0000000000000000000000000000000000000000000000000000000000000000";
6+
const EMPTY_WEB3_SIG: &str = "0x000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000";
7+
8+
/// Represents a computation result that can be uploaded to IPFS via the iExec result proxy.
9+
///
10+
/// This struct encapsulates all the necessary information about a completed computation task
11+
/// that needs to be stored permanently on IPFS. It includes task identification, metadata,
12+
/// the actual result data, and cryptographic proofs of computation integrity.
13+
///
14+
/// The struct is designed to be serialized to JSON for transmission to the result proxy API,
15+
/// with field names automatically converted to camelCase to match the expected API format.
16+
#[derive(Debug, Serialize, Deserialize)]
17+
#[serde(rename_all = "camelCase")]
18+
pub struct ResultModel {
19+
/// Unique identifier of the task on the blockchain
20+
pub chain_task_id: String,
21+
/// Unique identifier of the deal this task belongs to
22+
pub deal_id: String,
23+
/// Index of the task within the deal
24+
pub task_index: u32,
25+
/// Compressed result data as a byte array
26+
pub zip: Vec<u8>,
27+
/// Cryptographic hash of the computation result
28+
pub determinist_hash: String,
29+
/// TEE (Trusted Execution Environment) signature proving integrity
30+
pub enclave_signature: String,
31+
}
32+
33+
impl Default for ResultModel {
34+
fn default() -> Self {
35+
Self {
36+
chain_task_id: EMPTY_HEX_STRING_32.to_string(),
37+
deal_id: EMPTY_HEX_STRING_32.to_string(),
38+
task_index: 0,
39+
zip: vec![],
40+
determinist_hash: String::new(),
41+
enclave_signature: EMPTY_WEB3_SIG.to_string(),
42+
}
43+
}
44+
}
45+
46+
pub struct ResultProxyApiClient {
47+
base_url: String,
48+
client: Client,
49+
}
50+
51+
impl ResultProxyApiClient {
52+
/// Creates a new HTTP client for interacting with the iExec result proxy API.
53+
///
54+
/// This function initializes a client with the provided base URL. The client can then be used
55+
/// to upload computation results to IPFS via the result proxy service.
56+
///
57+
/// # Arguments
58+
///
59+
/// * `base_url` - The base URL of the result proxy service (e.g., "https://result.v8-bellecour.iex.ec")
60+
///
61+
/// # Returns
62+
///
63+
/// A new `ResultProxyApiClient` instance configured with the provided base URL.
64+
///
65+
/// # Example
66+
///
67+
/// ```rust
68+
/// use crate::api::result_proxy_api_client::ResultProxyApiClient;
69+
///
70+
/// let client = ResultProxyApiClient::new("https://result.v8-bellecour.iex.ec");
71+
/// ```
72+
pub fn new(base_url: &str) -> Self {
73+
Self {
74+
base_url: base_url.to_string(),
75+
client: Client::new(),
76+
}
77+
}
78+
79+
/// Uploads a computation result to IPFS via the result proxy service.
80+
///
81+
/// This method sends a POST request to the result proxy's `/v1/results` endpoint with
82+
/// the provided result model. The result proxy validates the data, uploads it to IPFS,
83+
/// and returns the IPFS link for permanent storage.
84+
///
85+
/// The upload process involves several steps handled by the result proxy:
86+
/// 1. Authentication and authorization validation
87+
/// 2. Result data validation (signatures, hashes, etc.)
88+
/// 3. IPFS upload and pinning
89+
/// 4. Registration of the result link on the blockchain
90+
///
91+
/// # Arguments
92+
///
93+
/// * `authorization` - The bearer token for authenticating with the result proxy
94+
/// * `result_model` - The [`ResultModel`] containing the computation result to upload
95+
///
96+
/// # Returns
97+
///
98+
/// * `Ok(String)` - The IPFS link where the result was uploaded (e.g., "ipfs://QmHash...")
99+
/// * `Err(reqwest::Error)` - HTTP client error or server-side error
100+
///
101+
/// # Errors
102+
///
103+
/// This function will return an error in the following situations:
104+
/// * Network connectivity issues preventing the HTTP request
105+
/// * Authentication failures (invalid or expired token)
106+
/// * Server-side validation failures (invalid signatures, malformed data)
107+
/// * IPFS upload failures on the result proxy side
108+
/// * HTTP status codes indicating server errors (4xx, 5xx)
109+
///
110+
/// # Example
111+
///
112+
/// ```rust
113+
/// use crate::api::result_proxy_api_client::{ResultProxyApiClient, ResultModel};
114+
///
115+
/// let client = ResultProxyApiClient::new("https://result-proxy.iex.ec");
116+
/// let result_model = ResultModel {
117+
/// chain_task_id: "0x123...".to_string(),
118+
/// zip: compressed_data,
119+
/// determinist_hash: computed_hash,
120+
/// enclave_signature: tee_signature,
121+
/// ..Default::default()
122+
/// };
123+
///
124+
/// match client.upload_to_ipfs("Bearer token123", &result_model) {
125+
/// Ok(ipfs_link) => {
126+
/// println!("Successfully uploaded to: {}", ipfs_link);
127+
/// // IPFS link can be used to retrieve the result later
128+
/// }
129+
/// Err(e) => {
130+
/// eprintln!("Upload failed: {}", e);
131+
/// // Handle error appropriately (retry, report, etc.)
132+
/// }
133+
/// }
134+
/// ```
135+
pub fn upload_to_ipfs(
136+
&self,
137+
authorization: &str,
138+
result_model: &ResultModel,
139+
) -> Result<String, reqwest::Error> {
140+
let url = format!("{}/v1/results", self.base_url);
141+
let response = self
142+
.client
143+
.post(&url)
144+
.header("Authorization", authorization)
145+
.json(result_model)
146+
.send()?;
147+
148+
if response.status().is_success() {
149+
response.text()
150+
} else {
151+
Err(response.error_for_status().unwrap_err())
152+
}
153+
}
154+
}
155+
156+
#[cfg(test)]
157+
mod tests {
158+
use super::*;
159+
use serde_json::json;
160+
use wiremock::{
161+
Mock, MockServer, ResponseTemplate,
162+
matchers::{body_json, header, method, path},
163+
};
164+
165+
// Test constants
166+
const TEST_TASK_ID: &str = "0x123";
167+
const TEST_DEAL_ID: &str = "0x456";
168+
const TEST_DETERMINIST_HASH: &str = "0xabc";
169+
const TEST_ENCLAVE_SIGNATURE: &str = "0xdef";
170+
const TEST_IPFS_LINK: &str = "ipfs://QmHash123";
171+
const TEST_TOKEN: &str = "test-token";
172+
173+
// region ResultModel
174+
#[test]
175+
fn result_model_default_returns_correct_values_when_created() {
176+
let model = ResultModel::default();
177+
assert_eq!(model.chain_task_id, EMPTY_HEX_STRING_32);
178+
assert_eq!(model.deal_id, EMPTY_HEX_STRING_32);
179+
assert_eq!(model.task_index, 0);
180+
assert!(model.zip.is_empty());
181+
assert_eq!(model.determinist_hash, "");
182+
assert_eq!(model.enclave_signature, EMPTY_WEB3_SIG);
183+
}
184+
185+
#[test]
186+
fn result_model_serializes_to_camel_case_when_converted_to_json() {
187+
let model = ResultModel {
188+
chain_task_id: TEST_TASK_ID.to_string(),
189+
deal_id: TEST_DEAL_ID.to_string(),
190+
task_index: 5,
191+
zip: vec![1, 2, 3],
192+
determinist_hash: TEST_DETERMINIST_HASH.to_string(),
193+
enclave_signature: TEST_ENCLAVE_SIGNATURE.to_string(),
194+
};
195+
196+
let expected = json!({
197+
"chainTaskId": TEST_TASK_ID,
198+
"dealId": TEST_DEAL_ID,
199+
"taskIndex": 5,
200+
"zip": [1, 2, 3],
201+
"deterministHash": TEST_DETERMINIST_HASH,
202+
"enclaveSignature": TEST_ENCLAVE_SIGNATURE
203+
});
204+
205+
let v = serde_json::to_value(model).unwrap();
206+
assert_eq!(v, expected);
207+
}
208+
209+
#[test]
210+
fn result_model_deserializes_from_camel_case_when_parsing_json() {
211+
let value = json!({
212+
"chainTaskId": TEST_TASK_ID,
213+
"dealId": TEST_DEAL_ID,
214+
"taskIndex": 5,
215+
"zip": [1, 2, 3],
216+
"deterministHash": TEST_DETERMINIST_HASH,
217+
"enclaveSignature": TEST_ENCLAVE_SIGNATURE
218+
});
219+
220+
let model: ResultModel = serde_json::from_value(value).unwrap();
221+
222+
assert_eq!(model.chain_task_id, TEST_TASK_ID);
223+
assert_eq!(model.deal_id, TEST_DEAL_ID);
224+
assert_eq!(model.task_index, 5);
225+
assert_eq!(model.zip, vec![1, 2, 3]);
226+
assert_eq!(model.determinist_hash, TEST_DETERMINIST_HASH);
227+
assert_eq!(model.enclave_signature, TEST_ENCLAVE_SIGNATURE);
228+
}
229+
//endregion
230+
231+
// region ResultProxyApiClient
232+
#[test]
233+
fn result_proxy_api_client_new_creates_client_when_given_base_url() {
234+
let base_url = "http://localhost:8080";
235+
let client = ResultProxyApiClient::new(base_url);
236+
assert_eq!(client.base_url, base_url);
237+
}
238+
239+
#[tokio::test]
240+
async fn upload_to_ipfs_returns_ipfs_link_when_server_responds_successfully() {
241+
let zip_content = b"test content";
242+
243+
let expected_model = ResultModel {
244+
chain_task_id: TEST_TASK_ID.to_string(),
245+
determinist_hash: TEST_DETERMINIST_HASH.to_string(),
246+
enclave_signature: TEST_ENCLAVE_SIGNATURE.to_string(),
247+
zip: zip_content.to_vec(),
248+
..Default::default()
249+
};
250+
251+
let mock_server = MockServer::start().await;
252+
let json = serde_json::to_value(&expected_model).unwrap();
253+
Mock::given(method("POST"))
254+
.and(path("/v1/results"))
255+
.and(header("Authorization", TEST_TOKEN))
256+
.and(body_json(json))
257+
.respond_with(ResponseTemplate::new(200).set_body_string(TEST_IPFS_LINK))
258+
.mount(&mock_server)
259+
.await;
260+
261+
let result = tokio::task::spawn_blocking(move || {
262+
let client = ResultProxyApiClient::new(&mock_server.uri());
263+
client.upload_to_ipfs(TEST_TOKEN, &expected_model)
264+
})
265+
.await
266+
.expect("Task panicked");
267+
268+
assert!(result.is_ok());
269+
assert_eq!(result.unwrap(), TEST_IPFS_LINK);
270+
}
271+
272+
#[tokio::test]
273+
async fn upload_to_ipfs_returns_error_for_all_error_codes() {
274+
let test_cases = vec![
275+
(400, "400", "Bad Request"),
276+
(401, "401", "Unauthorized"),
277+
(403, "403", "Forbidden"),
278+
(404, "404", "Not Found"),
279+
(500, "500", "Internal Server Error"),
280+
(502, "502", "Bad Gateway"),
281+
(503, "503", "Service Unavailable"),
282+
];
283+
284+
for (status_code, expected_error_contains, description) in test_cases {
285+
let mock_server = MockServer::start().await;
286+
Mock::given(method("POST"))
287+
.and(path("/v1/results"))
288+
.respond_with(
289+
ResponseTemplate::new(status_code)
290+
.set_body_string(format!("{} Error", status_code)),
291+
)
292+
.mount(&mock_server)
293+
.await;
294+
295+
let result = tokio::task::spawn_blocking(move || {
296+
let client = ResultProxyApiClient::new(&mock_server.uri());
297+
let model = ResultModel::default();
298+
client.upload_to_ipfs(TEST_TOKEN, &model)
299+
})
300+
.await
301+
.expect("Task panicked");
302+
303+
assert!(
304+
result.is_err(),
305+
"Expected error for status code {} ({})",
306+
status_code,
307+
description
308+
);
309+
let error = result.unwrap_err();
310+
assert!(
311+
error.to_string().contains(expected_error_contains),
312+
"Error message should contain '{}' for status code {} ({}), but got: {}",
313+
expected_error_contains,
314+
status_code,
315+
description,
316+
error
317+
);
318+
}
319+
}
320+
// endregion
321+
}

src/compute.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ pub mod computed_file;
33
pub mod errors;
44
pub mod signer;
55
pub mod utils;
6+
pub mod web2_result;

src/compute/app_runner.rs

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use crate::compute::{
66
errors::ReplicateStatusCause,
77
signer::get_challenge,
88
utils::env_utils::{TeeSessionEnvironmentVariable, get_env_var_or_error},
9+
web2_result::{Web2ResultInterface, Web2ResultService},
910
};
1011
use log::{error, info};
1112
use std::error::Error;
@@ -70,6 +71,12 @@ impl PostComputeRunnerInterface for DefaultPostComputeRunner {
7071
build_result_digest_in_computed_file(&mut computed_file, should_callback)?;
7172
sign_computed_file(&mut computed_file).map_err(Box::new)?;
7273

74+
if !should_callback {
75+
Web2ResultService
76+
.encrypt_and_upload_result(&computed_file)
77+
.map_err(Box::new)?;
78+
}
79+
7380
self.send_computed_file(&computed_file).map_err(Box::new)?;
7481

7582
Ok(())
@@ -548,15 +555,23 @@ mod tests {
548555

549556
#[test]
550557
fn send_computed_file_fails_when_get_challenge_fails() {
551-
let runner = DefaultPostComputeRunner::new();
552-
let computed_file = create_test_computed_file(Some(TEST_TASK_ID.to_string()));
553-
let result = runner.send_computed_file(&computed_file);
558+
with_vars(
559+
vec![(
560+
TeeSessionEnvironmentVariable::SignWorkerAddress.name(),
561+
None::<&str>,
562+
)],
563+
|| {
564+
let runner = DefaultPostComputeRunner::new();
565+
let computed_file = create_test_computed_file(Some(TEST_TASK_ID.to_string()));
566+
let result = runner.send_computed_file(&computed_file);
554567

555-
assert!(result.is_err(), "Should fail when get_challenge fails");
556-
assert_eq!(
557-
result.unwrap_err(),
558-
ReplicateStatusCause::PostComputeWorkerAddressMissing,
559-
"Should propagate the error from get_challenge"
568+
assert!(result.is_err(), "Should fail when get_challenge fails");
569+
assert_eq!(
570+
result.unwrap_err(),
571+
ReplicateStatusCause::PostComputeWorkerAddressMissing,
572+
"Should propagate the error from get_challenge"
573+
);
574+
},
560575
);
561576
}
562577

0 commit comments

Comments
 (0)