#![allow(dead_code)]
use anyhow::{anyhow, Context, Result};
use rand::RngCore;
use reqwest::header::{HeaderMap, HeaderValue};
use reqwest::multipart;
use serde::Deserialize;
use sha2::{Digest, Sha256};
use solana_sdk::signature::Keypair;
use solana_sdk::signer::Signer;
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
const AUTH_VERSION_TAG: &str = "solignition-auth-v1";
const EMPTY_BODY_HASH: &str =
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
#[derive(Debug)]
struct AuthHeaders {
pubkey_b58: String,
timestamp_ms: String,
nonce_b58: String,
signature_b58: String,
}
impl AuthHeaders {
fn apply(self, mut req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
req = req.header("X-Auth-Pubkey", self.pubkey_b58);
req = req.header("X-Auth-Timestamp", self.timestamp_ms);
req = req.header("X-Auth-Nonce", self.nonce_b58);
req = req.header("X-Auth-Signature", self.signature_b58);
req
}
}
fn sha256_hex(bytes: &[u8]) -> String {
let digest = Sha256::digest(bytes);
hex::encode(digest)
}
#[derive(Debug, Deserialize)]
pub struct UploadResponse {
pub success: bool,
#[serde(rename = "fileId")]
pub file_id: String,
#[serde(rename = "estimatedCost")]
pub estimated_cost: f64,
#[serde(rename = "binaryHash")]
pub binary_hash: String,
pub message: String,
}
#[derive(Debug, Deserialize)]
pub struct FileUploadInfo {
#[serde(rename = "fileId")]
pub file_id: String,
pub borrower: String,
#[serde(rename = "fileName")]
pub file_name: String,
#[serde(rename = "fileSize")]
pub file_size: u64,
#[serde(rename = "binaryHash")]
pub binary_hash: String,
#[serde(rename = "estimatedCost")]
pub estimated_cost: f64,
pub status: String,
#[serde(rename = "createdAt")]
pub created_at: u64,
}
#[derive(Debug, Deserialize)]
pub struct NotifyLoanResponse {
pub success: bool,
pub message: String,
pub signature: Option<String>,
#[serde(rename = "fileId")]
pub file_id: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct NotifyRepaidResponse {
pub success: bool,
pub message: String,
pub tx: Option<String>,
#[serde(rename = "loanId")]
pub loan_id: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct DeploymentInfo {
#[serde(rename = "loanId")]
pub loan_id: String,
pub borrower: String,
#[serde(rename = "programId")]
pub program_id: Option<String>,
#[serde(rename = "deploymentCost")]
pub deployment_cost: Option<f64>,
#[serde(rename = "deployTxSignature")]
pub deploy_tx_signature: Option<String>,
#[serde(rename = "setDeployedTxSignature")]
pub set_deployed_tx_signature: Option<String>,
#[serde(rename = "recoveryTxSignature")]
pub recovery_tx_signature: Option<String>,
pub status: String,
pub error: Option<String>,
#[serde(rename = "createdAt")]
pub created_at: u64,
#[serde(rename = "updatedAt")]
pub updated_at: u64,
#[serde(rename = "binaryHash")]
pub binary_hash: Option<String>,
pub principal: Option<String>,
#[serde(rename = "programAccountOpen")]
pub program_account_open: Option<bool>,
}
#[derive(Debug, Deserialize)]
pub struct HealthResponse {
pub status: String,
#[serde(rename = "activeLoans")]
pub active_loans: Option<u64>,
#[serde(rename = "totalDeployments")]
pub total_deployments: Option<u64>,
pub timestamp: Option<String>,
}
#[derive(Debug, Deserialize)]
struct PaginatedUploads {
uploads: Vec<FileUploadInfo>,
#[allow(dead_code)]
total: u64,
#[serde(rename = "hasMore")]
#[allow(dead_code)]
has_more: bool,
}
#[derive(Debug, Deserialize)]
struct PaginatedDeployments {
deployments: Vec<DeploymentInfo>,
#[allow(dead_code)]
total: u64,
#[serde(rename = "hasMore")]
#[allow(dead_code)]
has_more: bool,
}
#[derive(Debug, Deserialize)]
pub struct ErrorResponse {
pub error: String,
}
pub struct DeployerClient {
client: reqwest::Client,
base_url: String,
signer: Option<Arc<Keypair>>,
}
impl DeployerClient {
fn build_client() -> reqwest::Client {
let mut default_headers = HeaderMap::new();
default_headers.insert(
"ngrok-skip-browser-warning",
HeaderValue::from_static("true"),
);
reqwest::Client::builder()
.user_agent(concat!("solignition-cli/", env!("CARGO_PKG_VERSION")))
.default_headers(default_headers)
.timeout(Duration::from_secs(60))
.connect_timeout(Duration::from_secs(10))
.build()
.expect("failed to build HTTP client")
}
pub fn new(base_url: &str, signer: Arc<Keypair>) -> Self {
Self {
client: Self::build_client(),
base_url: base_url.trim_end_matches('/').to_string(),
signer: Some(signer),
}
}
pub fn new_anonymous(base_url: &str) -> Self {
Self {
client: Self::build_client(),
base_url: base_url.trim_end_matches('/').to_string(),
signer: None,
}
}
fn signer(&self) -> Result<&Keypair> {
self.signer
.as_deref()
.ok_or_else(|| anyhow!("DeployerClient was constructed anonymously; cannot sign request"))
}
fn sign_request(&self, method: &str, path: &str, body_hash_hex: &str) -> Result<AuthHeaders> {
let signer = self.signer()?;
let timestamp_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis().to_string())
.unwrap_or_else(|_| "0".to_string());
let mut nonce_bytes = [0u8; 16];
rand::thread_rng().fill_bytes(&mut nonce_bytes);
let nonce_b58 = bs58::encode(nonce_bytes).into_string();
let canonical = format!(
"{tag}\n{method}\n{path}\n{ts}\n{nonce}\n{body_hash}",
tag = AUTH_VERSION_TAG,
method = method,
path = path,
ts = timestamp_ms,
nonce = nonce_b58,
body_hash = body_hash_hex,
);
let signature = signer.sign_message(canonical.as_bytes());
let signature_b58 = bs58::encode(signature.as_ref()).into_string();
Ok(AuthHeaders {
pubkey_b58: signer.pubkey().to_string(),
timestamp_ms,
nonce_b58,
signature_b58,
})
}
pub async fn upload_file(&self, file_path: &Path, borrower: &str) -> Result<UploadResponse> {
let file_name = file_path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let file_bytes = tokio::fs::read(file_path).await
.with_context(|| format!("Failed to read file: {}", file_path.display()))?;
let local_hash = sha256_hex(&file_bytes);
let auth = self.sign_request("POST", "/v1/uploads", &local_hash)?;
let file_part = multipart::Part::bytes(file_bytes)
.file_name(file_name)
.mime_str("application/octet-stream")?;
let form = multipart::Form::new()
.text("borrower", borrower.to_string())
.text("expectedHash", local_hash.clone())
.part("file", file_part);
let req = self
.client
.post(format!("{}/v1/uploads", self.base_url))
.multipart(form);
let resp = auth
.apply(req)
.send()
.await
.context("Failed to connect to deployer API")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("Upload failed ({}): {}", status, body);
}
let parsed: UploadResponse = resp
.json()
.await
.context("Failed to parse upload response")?;
if !parsed.binary_hash.eq_ignore_ascii_case(&local_hash) {
anyhow::bail!(
"Upload integrity check failed: deployer reported binary_hash `{}` but local file hash is `{}`",
parsed.binary_hash,
local_hash,
);
}
Ok(parsed)
}
pub async fn get_upload(&self, file_id: &str) -> Result<FileUploadInfo> {
let path = format!("/v1/uploads/{}", file_id);
let auth = self.sign_request("GET", &path, EMPTY_BODY_HASH)?;
let req = self.client.get(format!("{}{}", self.base_url, path));
let resp = auth
.apply(req)
.send()
.await
.context("Failed to connect to deployer API")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("Failed to get upload ({}): {}", status, body);
}
resp.json::<FileUploadInfo>()
.await
.context("Failed to parse upload info")
}
pub async fn get_uploads_by_borrower(&self, borrower: &str) -> Result<Vec<FileUploadInfo>> {
let path = format!("/v1/uploads?borrower={}&limit=200&offset=0", borrower);
let auth = self.sign_request("GET", &path, EMPTY_BODY_HASH)?;
let req = self.client.get(format!("{}{}", self.base_url, path));
let resp = auth
.apply(req)
.send()
.await
.context("Failed to connect to deployer API")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("Failed to get uploads ({}): {}", status, body);
}
let env: PaginatedUploads = resp
.json()
.await
.context("Failed to parse uploads envelope")?;
Ok(env.uploads)
}
pub async fn notify_loan(
&self,
signature: &str,
borrower: &str,
loan_id: &str,
file_id: &str,
) -> Result<NotifyLoanResponse> {
let body = serde_json::json!({
"signature": signature,
"borrower": borrower,
"loanId": loan_id,
"fileId": file_id,
});
let body_bytes = serde_json::to_vec(&body)
.context("Failed to serialize notify-loan body")?;
let body_hash = sha256_hex(&body_bytes);
let auth = self.sign_request("POST", "/v1/loans", &body_hash)?;
let req = self
.client
.post(format!("{}/v1/loans", self.base_url))
.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(body_bytes);
let resp = auth
.apply(req)
.send()
.await
.context("Failed to connect to deployer API")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("Failed to notify loan ({}): {}", status, body);
}
resp.json::<NotifyLoanResponse>()
.await
.context("Failed to parse notify response")
}
pub async fn notify_repaid(
&self,
signature: &str,
borrower: &str,
loan_id: u64,
) -> Result<NotifyRepaidResponse> {
let body = serde_json::json!({
"signature": signature,
"borrower": borrower,
});
let body_bytes = serde_json::to_vec(&body)
.context("Failed to serialize notify-repaid body")?;
let body_hash = sha256_hex(&body_bytes);
let path = format!("/v1/loans/{}/repayments", loan_id);
let auth = self.sign_request("POST", &path, &body_hash)?;
let req = self
.client
.post(format!("{}{}", self.base_url, path))
.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(body_bytes);
let resp = auth
.apply(req)
.send()
.await
.context("Failed to connect to deployer API")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("Failed to notify repaid ({}): {}", status, body);
}
resp.json::<NotifyRepaidResponse>()
.await
.context("Failed to parse repaid response")
}
pub async fn get_deployment(&self, loan_id: &str) -> Result<DeploymentInfo> {
let path = format!("/v1/deployments/{}", loan_id);
let auth = self.sign_request("GET", &path, EMPTY_BODY_HASH)?;
let req = self.client.get(format!("{}{}", self.base_url, path));
let resp = auth
.apply(req)
.send()
.await
.context("Failed to connect to deployer API")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("Failed to get deployment ({}): {}", status, body);
}
resp.json::<DeploymentInfo>()
.await
.context("Failed to parse deployment info")
}
pub async fn get_deployments_by_borrower(
&self,
borrower: &str,
) -> Result<Vec<DeploymentInfo>> {
let path = format!("/v1/deployments?borrower={}&limit=200&offset=0", borrower);
let auth = self.sign_request("GET", &path, EMPTY_BODY_HASH)?;
let req = self.client.get(format!("{}{}", self.base_url, path));
let resp = auth
.apply(req)
.send()
.await
.context("Failed to connect to deployer API")?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
anyhow::bail!("Failed to get deployments ({}): {}", status, body);
}
let env: PaginatedDeployments = resp
.json()
.await
.context("Failed to parse deployments envelope")?;
Ok(env.deployments)
}
pub async fn health(&self) -> Result<HealthResponse> {
let resp = self
.client
.get(format!("{}/health", self.base_url))
.send()
.await
.context("Failed to connect to deployer API — is the service running?")?;
resp.json::<HealthResponse>()
.await
.context("Failed to parse health response")
}
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use solana_sdk::signature::{Keypair, SeedDerivable};
use std::sync::Arc;
fn fixture_keypair() -> Keypair {
let mut seed = [0u8; 32];
for (i, b) in seed.iter_mut().enumerate() {
*b = (i as u8).wrapping_mul(7).wrapping_add(13);
}
Keypair::from_seed(&seed).expect("valid 32-byte seed")
}
#[test]
fn sha256_hex_empty_string_matches_constant() {
assert_eq!(sha256_hex(b""), EMPTY_BODY_HASH);
}
#[test]
fn sha256_hex_known_vector() {
assert_eq!(
sha256_hex(b"abc"),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
);
}
#[test]
fn canonical_message_has_exact_six_line_layout() {
let timestamp_ms = "1700000000000";
let nonce_b58 = "11111111111111111111";
let canonical = format!(
"{tag}\n{method}\n{path}\n{ts}\n{nonce}\n{body_hash}",
tag = AUTH_VERSION_TAG,
method = "POST",
path = "/v1/uploads",
ts = timestamp_ms,
nonce = nonce_b58,
body_hash = EMPTY_BODY_HASH,
);
let lines: Vec<&str> = canonical.split('\n').collect();
assert_eq!(lines.len(), 6, "canonical must be exactly 6 newline-separated lines");
assert_eq!(lines[0], "solignition-auth-v1");
assert_eq!(lines[1], "POST");
assert_eq!(lines[2], "/v1/uploads");
assert_eq!(lines[3], "1700000000000");
assert_eq!(lines[4], "11111111111111111111");
assert_eq!(lines[5], EMPTY_BODY_HASH);
}
#[test]
fn sign_request_round_trip_verifies() {
let keypair = fixture_keypair();
let client = DeployerClient::new("http://localhost:0", Arc::new(keypair));
let verify_keypair = fixture_keypair();
let pubkey_bytes: [u8; 32] = verify_keypair.pubkey().to_bytes();
let verifying_key = VerifyingKey::from_bytes(&pubkey_bytes)
.expect("pubkey is a valid ed25519 point");
let body_hash = sha256_hex(b"{\"hello\":\"world\"}");
let headers = client
.sign_request("POST", "/v1/loans", &body_hash)
.expect("sign_request must succeed for an authenticated client");
let canonical = format!(
"{tag}\n{method}\n{path}\n{ts}\n{nonce}\n{body_hash}",
tag = AUTH_VERSION_TAG,
method = "POST",
path = "/v1/loans",
ts = headers.timestamp_ms,
nonce = headers.nonce_b58,
body_hash = body_hash,
);
let sig_bytes_vec = bs58::decode(&headers.signature_b58)
.into_vec()
.expect("signature is valid base58");
let sig_bytes: [u8; 64] = sig_bytes_vec
.try_into()
.expect("signature is 64 bytes");
let signature = Signature::from_bytes(&sig_bytes);
verifying_key
.verify(canonical.as_bytes(), &signature)
.expect("signature must verify against the same canonical bytes");
let header_pubkey_bytes_vec = bs58::decode(&headers.pubkey_b58)
.into_vec()
.expect("pubkey header is valid base58");
assert_eq!(header_pubkey_bytes_vec.as_slice(), pubkey_bytes.as_slice());
}
#[test]
fn sign_request_fails_on_anonymous_client() {
let client = DeployerClient::new_anonymous("http://localhost:0");
let err = client
.sign_request("GET", "/v1/uploads/abc", EMPTY_BODY_HASH)
.expect_err("anonymous sign_request must fail");
assert!(
err.to_string().contains("anonymously"),
"error message must explain the anonymous-client mistake; got: {err}"
);
}
}