Skip to content

Commit 0ce3d2c

Browse files
authored
feat: add post-compute package (#2)
1 parent f26302d commit 0ce3d2c

22 files changed

+10863
-21
lines changed

Cargo.lock

Lines changed: 4522 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 & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
[workspace]
22
resolver = "3"
3-
members = ["add"]
3+
members = [
4+
"post-compute"
5+
]

add/Cargo.toml

Lines changed: 0 additions & 6 deletions
This file was deleted.

add/src/lib.rs

Lines changed: 0 additions & 14 deletions
This file was deleted.

post-compute/Cargo.toml

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
[package]
2+
name = "tee-worker-post-compute"
3+
version = "0.1.0"
4+
edition = "2024"
5+
6+
[[bin]]
7+
name = "tee-worker-post-compute"
8+
path = "src/bin/tee_worker_post_compute.rs"
9+
10+
[dependencies]
11+
aes = "0.8.4"
12+
alloy-signer = "0.15.9"
13+
alloy-signer-local = "0.15.9"
14+
cbc = { version = "0.1.2", features = ["alloc"] }
15+
env_logger = "0.11.8"
16+
base64 = "0.22.1"
17+
log = "0.4.27"
18+
rand = "0.8.5"
19+
rsa = "0.9.8"
20+
reqwest = { version = "0.12.15", features = ["blocking", "json"] }
21+
serde = "1.0.219"
22+
serde_json = "1.0.140"
23+
sha256 = "1.6.0"
24+
sha3 = "0.10.8"
25+
thiserror = "2.0.12"
26+
walkdir = "2.5.0"
27+
zip = "4.0.0"
28+
29+
[dev-dependencies]
30+
logtest = "2.0.0"
31+
mockall = "0.13.1"
32+
once_cell = "1.21.3"
33+
serial_test = "3.2.0"
34+
temp-env = "0.3.6"
35+
tempfile = "3.20.0"
36+
tokio = "1.45.0"
37+
wiremock = "0.6.3"

post-compute/src/api.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
pub mod result_proxy_api_client;
2+
pub mod worker_api;
Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,318 @@
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 tee_worker_post_compute::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 tee_worker_post_compute::api::result_proxy_api_client::{
114+
/// ResultProxyApiClient,
115+
/// ResultModel,
116+
/// };
117+
///
118+
/// let client = ResultProxyApiClient::new("https://result-proxy.iex.ec");
119+
/// let result_model = ResultModel {
120+
/// chain_task_id: "0x123...".to_string(),
121+
/// zip: vec![0xde, 0xad, 0xbe, 0xef],
122+
/// determinist_hash: "0xabc".to_string(),
123+
/// enclave_signature: "0xdef".to_string(),
124+
/// ..Default::default()
125+
/// };
126+
///
127+
/// match client.upload_to_ipfs("Bearer token123", &result_model) {
128+
/// Ok(ipfs_link) => {
129+
/// println!("Successfully uploaded to: {}", ipfs_link);
130+
/// // IPFS link can be used to retrieve the result later
131+
/// }
132+
/// Err(e) => {
133+
/// eprintln!("Upload failed: {}", e);
134+
/// // Handle error appropriately (retry, report, etc.)
135+
/// }
136+
/// }
137+
/// ```
138+
pub fn upload_to_ipfs(
139+
&self,
140+
authorization: &str,
141+
result_model: &ResultModel,
142+
) -> Result<String, reqwest::Error> {
143+
let url = format!("{}/v1/results", self.base_url);
144+
let response = self
145+
.client
146+
.post(&url)
147+
.header("Authorization", authorization)
148+
.json(result_model)
149+
.send()?;
150+
151+
if response.status().is_success() {
152+
response.text()
153+
} else {
154+
Err(response.error_for_status().unwrap_err())
155+
}
156+
}
157+
}
158+
159+
#[cfg(test)]
160+
mod tests {
161+
use super::*;
162+
use serde_json::json;
163+
use wiremock::{
164+
Mock, MockServer, ResponseTemplate,
165+
matchers::{body_json, header, method, path},
166+
};
167+
168+
// Test constants
169+
const TEST_TASK_ID: &str = "0x123";
170+
const TEST_DEAL_ID: &str = "0x456";
171+
const TEST_DETERMINIST_HASH: &str = "0xabc";
172+
const TEST_ENCLAVE_SIGNATURE: &str = "0xdef";
173+
const TEST_IPFS_LINK: &str = "ipfs://QmHash123";
174+
const TEST_TOKEN: &str = "test-token";
175+
176+
// region ResultModel
177+
#[test]
178+
fn result_model_default_returns_correct_values_when_created() {
179+
let model = ResultModel::default();
180+
assert_eq!(model.chain_task_id, EMPTY_HEX_STRING_32);
181+
assert_eq!(model.deal_id, EMPTY_HEX_STRING_32);
182+
assert_eq!(model.task_index, 0);
183+
assert!(model.zip.is_empty());
184+
assert_eq!(model.determinist_hash, "");
185+
assert_eq!(model.enclave_signature, EMPTY_WEB3_SIG);
186+
}
187+
188+
#[test]
189+
fn result_model_serializes_to_camel_case_when_converted_to_json() {
190+
let model = ResultModel {
191+
chain_task_id: TEST_TASK_ID.to_string(),
192+
deal_id: TEST_DEAL_ID.to_string(),
193+
task_index: 5,
194+
zip: vec![1, 2, 3],
195+
determinist_hash: TEST_DETERMINIST_HASH.to_string(),
196+
enclave_signature: TEST_ENCLAVE_SIGNATURE.to_string(),
197+
};
198+
199+
let expected = json!({
200+
"chainTaskId": TEST_TASK_ID,
201+
"dealId": TEST_DEAL_ID,
202+
"taskIndex": 5,
203+
"zip": [1, 2, 3],
204+
"deterministHash": TEST_DETERMINIST_HASH,
205+
"enclaveSignature": TEST_ENCLAVE_SIGNATURE
206+
});
207+
208+
let v = serde_json::to_value(model).unwrap();
209+
assert_eq!(v, expected);
210+
}
211+
212+
#[test]
213+
fn result_model_deserializes_from_camel_case_when_parsing_json() {
214+
let value = json!({
215+
"chainTaskId": TEST_TASK_ID,
216+
"dealId": TEST_DEAL_ID,
217+
"taskIndex": 5,
218+
"zip": [1, 2, 3],
219+
"deterministHash": TEST_DETERMINIST_HASH,
220+
"enclaveSignature": TEST_ENCLAVE_SIGNATURE
221+
});
222+
223+
let model: ResultModel = serde_json::from_value(value).unwrap();
224+
225+
assert_eq!(model.chain_task_id, TEST_TASK_ID);
226+
assert_eq!(model.deal_id, TEST_DEAL_ID);
227+
assert_eq!(model.task_index, 5);
228+
assert_eq!(model.zip, vec![1, 2, 3]);
229+
assert_eq!(model.determinist_hash, TEST_DETERMINIST_HASH);
230+
assert_eq!(model.enclave_signature, TEST_ENCLAVE_SIGNATURE);
231+
}
232+
//endregion
233+
234+
// region ResultProxyApiClient
235+
#[test]
236+
fn result_proxy_api_client_new_creates_client_when_given_base_url() {
237+
let base_url = "http://localhost:8080";
238+
let client = ResultProxyApiClient::new(base_url);
239+
assert_eq!(client.base_url, base_url);
240+
}
241+
242+
#[tokio::test]
243+
async fn upload_to_ipfs_returns_ipfs_link_when_server_responds_successfully() {
244+
let zip_content = b"test content";
245+
246+
let expected_model = ResultModel {
247+
chain_task_id: TEST_TASK_ID.to_string(),
248+
determinist_hash: TEST_DETERMINIST_HASH.to_string(),
249+
enclave_signature: TEST_ENCLAVE_SIGNATURE.to_string(),
250+
zip: zip_content.to_vec(),
251+
..Default::default()
252+
};
253+
254+
let mock_server = MockServer::start().await;
255+
let json = serde_json::to_value(&expected_model).unwrap();
256+
Mock::given(method("POST"))
257+
.and(path("/v1/results"))
258+
.and(header("Authorization", TEST_TOKEN))
259+
.and(body_json(json))
260+
.respond_with(ResponseTemplate::new(200).set_body_string(TEST_IPFS_LINK))
261+
.mount(&mock_server)
262+
.await;
263+
264+
let result = tokio::task::spawn_blocking(move || {
265+
let client = ResultProxyApiClient::new(&mock_server.uri());
266+
client.upload_to_ipfs(TEST_TOKEN, &expected_model)
267+
})
268+
.await
269+
.expect("Task panicked");
270+
271+
assert!(result.is_ok());
272+
assert_eq!(result.unwrap(), TEST_IPFS_LINK);
273+
}
274+
275+
#[tokio::test]
276+
async fn upload_to_ipfs_returns_error_for_all_error_codes() {
277+
let test_cases = vec![
278+
(400, "400", "Bad Request"),
279+
(401, "401", "Unauthorized"),
280+
(403, "403", "Forbidden"),
281+
(404, "404", "Not Found"),
282+
(500, "500", "Internal Server Error"),
283+
(502, "502", "Bad Gateway"),
284+
(503, "503", "Service Unavailable"),
285+
];
286+
287+
for (status_code, expected_error_contains, description) in test_cases {
288+
let mock_server = MockServer::start().await;
289+
Mock::given(method("POST"))
290+
.and(path("/v1/results"))
291+
.respond_with(
292+
ResponseTemplate::new(status_code)
293+
.set_body_string(format!("{status_code} Error")),
294+
)
295+
.mount(&mock_server)
296+
.await;
297+
298+
let result = tokio::task::spawn_blocking(move || {
299+
let client = ResultProxyApiClient::new(&mock_server.uri());
300+
let model = ResultModel::default();
301+
client.upload_to_ipfs(TEST_TOKEN, &model)
302+
})
303+
.await
304+
.expect("Task panicked");
305+
306+
assert!(
307+
result.is_err(),
308+
"Expected error for status code {status_code} ({description})"
309+
);
310+
let error = result.unwrap_err();
311+
assert!(
312+
error.to_string().contains(expected_error_contains),
313+
"Error message should contain '{expected_error_contains}' for status code {status_code} ({description}), but got: {error}"
314+
);
315+
}
316+
}
317+
// endregion
318+
}

0 commit comments

Comments
 (0)