use crate::client::http::{ClientError, NamespaceConfig};
use crate::client::off_host_sign::{assemble_cose_sign1, prepare_sig_structure};
use crate::client::transport::{MultipartField, RequestBody};
use crate::client::types::{
MerkleLeaf, PublishContentInput, PublishMerkleInput, PublishMerkleResponse,
PublishPrehashedInput, PublishResponse, PublishSealedInput, SealedKemChoice, SupportedHashAlg,
UploadEntry, UploadsResponse,
};
use crate::hash::{blake2b256, sha256};
use crate::merkle::{encode_leaves_list, merkle_root, MERKLE_ALG_ID};
use crate::poe_standard::{
chunk_uri, encode_poe_record, EncryptionEnvelope, ItemEntry, MerkleCommit, PoeRecord, Slot,
};
use crate::sealed_poe::{ecies_sealed_poe_wrap_secure, SealedKem, SealedSlots, WrapArgs};
use crate::verifier::fetch::HttpMethod;
const ED25519_PUBLIC_KEY_LENGTH: usize = 32;
const ED25519_SIGNATURE_LENGTH: usize = 64;
const X25519_PUBLIC_KEY_LENGTH: usize = 32;
const MLKEM768X25519_PUBLIC_KEY_LENGTH: usize = 1216;
const LEAF_DIGEST_LENGTH: usize = 32;
const DIGEST_BYTE_LENGTH: usize = 32;
const STORAGE_TARGET_ARWEAVE: &str = "arweave";
pub trait Signer {
fn signer_pubkey(&self) -> Vec<u8>;
fn sign(&self, sig_structure_bytes: &[u8]) -> Result<Vec<u8>, SignerError>;
}
#[derive(Debug, thiserror::Error)]
#[error("signer failed: {0}")]
pub struct SignerError(pub String);
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
pub enum PublishError {
#[error("INVALID_SIGNER_PUBKEY: signer pubkey must be a 32-byte Ed25519 public key")]
InvalidSignerPubkey,
#[error("INVALID_SIGNER_SIGNATURE: signer must return a 64-byte Ed25519 signature")]
InvalidSignerSignature,
#[error("INVALID_LEAVES: a Merkle leaf is malformed")]
InvalidLeaves,
#[error("INVALID_DIGEST: a digest is the wrong length")]
InvalidDigest,
#[error("INVALID_RECIPIENT: a recipient public key is the wrong length for the chosen KEM")]
InvalidRecipient,
#[error("UNSUPPORTED_HASH_ALG: hash algorithm is not supported")]
UnsupportedHashAlg,
}
impl PublishError {
#[must_use]
pub const fn code(self) -> &'static str {
match self {
PublishError::InvalidSignerPubkey => "INVALID_SIGNER_PUBKEY",
PublishError::InvalidSignerSignature => "INVALID_SIGNER_SIGNATURE",
PublishError::InvalidLeaves => "INVALID_LEAVES",
PublishError::InvalidDigest => "INVALID_DIGEST",
PublishError::InvalidRecipient => "INVALID_RECIPIENT",
PublishError::UnsupportedHashAlg => "UNSUPPORTED_HASH_ALG",
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum PublishHelperError {
#[error(transparent)]
Validation(#[from] PublishError),
#[error(transparent)]
Signer(#[from] SignerError),
#[error(transparent)]
PartialUpload(#[from] PartialUploadError),
#[error(transparent)]
Http(#[from] ClientError),
#[error("crypto failure: {0}")]
Crypto(String),
}
#[derive(Debug, Clone, thiserror::Error)]
#[error("{} of {} upload(s) failed", .failed.len(), .response.uploads.len())]
pub struct PartialUploadError {
pub response: UploadsResponse,
pub failed: Vec<UploadEntry>,
}
impl PartialUploadError {
#[must_use]
pub fn new(response: UploadsResponse) -> Self {
let failed = response
.uploads
.iter()
.filter(|u| !u.is_ok())
.cloned()
.collect();
Self { response, failed }
}
#[must_use]
pub fn failed_indices(&self) -> Vec<u64> {
self.failed.iter().map(UploadEntry::idx).collect()
}
}
fn hash_content(bytes: &[u8], alg: SupportedHashAlg) -> Vec<u8> {
match alg {
SupportedHashAlg::Sha2_256 => sha256(bytes).to_vec(),
SupportedHashAlg::Blake2b256 => blake2b256(bytes).to_vec(),
}
}
fn assert_signer(signer: &dyn Signer) -> Result<(), PublishError> {
if signer.signer_pubkey().len() != ED25519_PUBLIC_KEY_LENGTH {
return Err(PublishError::InvalidSignerPubkey);
}
Ok(())
}
fn encode_record(
record: &PoeRecord,
signer: Option<&dyn Signer>,
) -> Result<Vec<u8>, PublishHelperError> {
let Some(signer) = signer else {
return encode_poe_record(record).map_err(|e| PublishHelperError::Crypto(e.to_string()));
};
let pubkey = signer.signer_pubkey();
let prepared =
prepare_sig_structure(record, &pubkey).map_err(|_| PublishError::InvalidSignerPubkey)?;
let signature = signer.sign(&prepared.sig_structure_bytes)?;
if signature.len() != ED25519_SIGNATURE_LENGTH {
return Err(PublishError::InvalidSignerSignature.into());
}
let assembled = assemble_cose_sign1(record, &pubkey, &signature)
.map_err(|_| PublishError::InvalidSignerSignature)?;
let mut signed = record.clone();
signed.sigs = Some(vec![assembled.sig_entry]);
encode_poe_record(&signed).map_err(|e| PublishHelperError::Crypto(e.to_string()))
}
fn post_publish(
config: &NamespaceConfig<'_>,
record_bytes: &[u8],
quote_id: &str,
idempotency_key: Option<&str>,
) -> Result<PublishResponse, PublishHelperError> {
let body = serde_json::json!({
"record": hex::encode(record_bytes),
"quote_id": quote_id,
});
let headers = crate::client::http::json_headers(config.api_key.as_deref(), idempotency_key);
let url = format!("{}/api/v1/poe/publish", config.base_url);
let response = crate::client::http::send(
config.transport,
&url,
HttpMethod::Post,
&headers,
&RequestBody::Json(serde_json::to_string(&body).expect("publish body serialises")),
)?;
let dedup_hit = response.status == 200;
let mut parsed: PublishResponse = crate::client::http::decode(&response.body)?;
parsed.dedup_hit = dedup_hit;
Ok(parsed)
}
fn post_uploads(
config: &NamespaceConfig<'_>,
blobs: &[Vec<u8>],
idempotency_key: Option<&str>,
) -> Result<UploadsResponse, PublishHelperError> {
let mut fields = vec![MultipartField {
name: "target".to_string(),
filename: None,
content_type: None,
value: STORAGE_TARGET_ARWEAVE.as_bytes().to_vec(),
}];
for (idx, bytes) in blobs.iter().enumerate() {
fields.push(MultipartField {
name: format!("file_{idx}"),
filename: Some(format!("file_{idx}.bin")),
content_type: Some("application/octet-stream".to_string()),
value: bytes.clone(),
});
}
let headers =
crate::client::http::multipart_headers(config.api_key.as_deref(), idempotency_key);
let url = format!("{}/api/v1/poe/uploads", config.base_url);
let response = crate::client::http::send(
config.transport,
&url,
HttpMethod::Post,
&headers,
&RequestBody::Multipart(fields),
)?;
let result: UploadsResponse = crate::client::http::decode(&response.body)?;
if result.uploads.iter().any(|u| !u.is_ok()) {
return Err(PartialUploadError::new(result).into());
}
Ok(result)
}
fn first_success_uri(response: &UploadsResponse) -> Result<String, PublishHelperError> {
response
.uploads
.iter()
.find_map(|u| match u {
UploadEntry::Success { uri, .. } => Some(uri.clone()),
UploadEntry::Failure { .. } => None,
})
.ok_or_else(|| PublishHelperError::Crypto("uploads response carried no success URI".into()))
}
pub fn publish_content(
config: &NamespaceConfig<'_>,
input: &PublishContentInput<'_>,
) -> Result<PublishResponse, PublishHelperError> {
if let Some(signer) = input.signer {
assert_signer(signer)?;
}
let hash_alg = input.hash_alg.unwrap_or(SupportedHashAlg::Sha2_256);
let digest = hash_content(&input.content, hash_alg);
let record = PoeRecord {
v: 1,
items: Some(vec![ItemEntry {
hashes: vec![(hash_alg.as_str().to_string(), digest)],
uris: None,
enc: None,
}]),
..PoeRecord::default()
};
let record_bytes = encode_record(&record, input.signer)?;
post_publish(
config,
&record_bytes,
&input.quote_id,
input.idempotency_key.as_deref(),
)
}
pub fn publish_prehashed(
config: &NamespaceConfig<'_>,
input: &PublishPrehashedInput<'_>,
) -> Result<PublishResponse, PublishHelperError> {
if let Some(signer) = input.signer {
assert_signer(signer)?;
}
let present: Vec<&(SupportedHashAlg, String)> = input
.hashes
.iter()
.filter(|(_, hex)| !hex.is_empty())
.collect();
if present.is_empty() {
return Err(PublishError::InvalidDigest.into());
}
let mut hashes: Vec<(String, Vec<u8>)> = Vec::new();
for (alg, hex_str) in present {
let bytes = hex::decode(hex_str).map_err(|_| PublishError::InvalidDigest)?;
if bytes.len() != DIGEST_BYTE_LENGTH {
return Err(PublishError::InvalidDigest.into());
}
hashes.push((alg.as_str().to_string(), bytes));
}
let record = PoeRecord {
v: 1,
items: Some(vec![ItemEntry {
hashes,
uris: None,
enc: None,
}]),
..PoeRecord::default()
};
let record_bytes = encode_record(&record, input.signer)?;
post_publish(
config,
&record_bytes,
&input.quote_id,
input.idempotency_key.as_deref(),
)
}
pub fn publish_sealed(
config: &NamespaceConfig<'_>,
input: &PublishSealedInput<'_>,
) -> Result<PublishResponse, PublishHelperError> {
if let Some(signer) = input.signer {
assert_signer(signer)?;
}
if input.recipients.is_empty() {
return Err(PublishError::InvalidRecipient.into());
}
let kem = input.kem.unwrap_or(SealedKemChoice::Mlkem768X25519);
let expected_len = match kem {
SealedKemChoice::X25519 => X25519_PUBLIC_KEY_LENGTH,
SealedKemChoice::Mlkem768X25519 => MLKEM768X25519_PUBLIC_KEY_LENGTH,
};
if input.recipients.iter().any(|r| r.len() != expected_len) {
return Err(PublishError::InvalidRecipient.into());
}
let hash_alg = input.hash_alg.unwrap_or(SupportedHashAlg::Sha2_256);
let plaintext_digest = hash_content(&input.content, hash_alg);
let sealed_kem = match kem {
SealedKemChoice::X25519 => SealedKem::X25519,
SealedKemChoice::Mlkem768X25519 => SealedKem::Mlkem768X25519,
};
let sealed = ecies_sealed_poe_wrap_secure(WrapArgs {
plaintext: &input.content,
recipient_public_keys: &input.recipients,
kem: Some(sealed_kem),
..WrapArgs::default()
})
.map_err(|e| PublishHelperError::Crypto(e.to_string()))?;
let uploads = post_uploads(
config,
&[sealed.ciphertext],
input.idempotency_key.as_deref(),
)?;
let uri = first_success_uri(&uploads)?;
let envelope = build_envelope(&sealed.envelope);
let record = PoeRecord {
v: 1,
items: Some(vec![ItemEntry {
hashes: vec![(hash_alg.as_str().to_string(), plaintext_digest)],
uris: Some(vec![chunk_uri(&uri)]),
enc: Some(envelope),
}]),
..PoeRecord::default()
};
let record_bytes = encode_record(&record, input.signer)?;
post_publish(
config,
&record_bytes,
&input.quote_id,
input.idempotency_key.as_deref(),
)
}
fn build_envelope(env: &crate::sealed_poe::SealedEnvelope) -> EncryptionEnvelope {
let slots = match &env.slots {
SealedSlots::X25519(slots) => slots
.iter()
.map(|s| Slot {
epk: Some(s.epk.clone()),
kem_ct: None,
wrap: Some(s.wrap.clone()),
})
.collect(),
SealedSlots::Mlkem768X25519(slots) => slots
.iter()
.map(|s| Slot {
epk: None,
kem_ct: Some(s.kem_ct.clone()),
wrap: Some(s.wrap.clone()),
})
.collect(),
};
EncryptionEnvelope {
scheme: u64::try_from(env.scheme).unwrap_or(1),
aead: env.aead.clone(),
nonce: env.nonce.clone(),
kem: Some(env.kem.clone()),
slots: Some(slots),
slots_mac: Some(env.slots_mac.clone()),
passphrase: None,
}
}
pub fn publish_merkle(
config: &NamespaceConfig<'_>,
input: &PublishMerkleInput<'_>,
) -> Result<PublishMerkleResponse, PublishHelperError> {
if let Some(signer) = input.signer {
assert_signer(signer)?;
}
if let Some(alg) = input.hash_alg {
if alg != SupportedHashAlg::Sha2_256 {
return Err(PublishError::UnsupportedHashAlg.into());
}
}
if input.leaves.is_empty() {
return Err(PublishError::InvalidLeaves.into());
}
let mut leaves: Vec<[u8; LEAF_DIGEST_LENGTH]> = Vec::with_capacity(input.leaves.len());
for leaf in &input.leaves {
let bytes = match leaf {
MerkleLeaf::Bytes(b) => b.clone(),
MerkleLeaf::Hex(h) => hex::decode(h).map_err(|_| PublishError::InvalidLeaves)?,
};
let arr: [u8; LEAF_DIGEST_LENGTH] =
bytes.try_into().map_err(|_| PublishError::InvalidLeaves)?;
leaves.push(arr);
}
let root = merkle_root(&leaves).map_err(|e| PublishHelperError::Crypto(e.to_string()))?;
let leaves_list = encode_leaves_list(&leaves, &root, None)
.map_err(|e| PublishHelperError::Crypto(e.to_string()))?;
let uploads = post_uploads(config, &[leaves_list], input.idempotency_key.as_deref())?;
let uri = first_success_uri(&uploads)?;
let merkle_entry = MerkleCommit {
alg: MERKLE_ALG_ID.to_string(),
root: root.to_vec(),
leaf_count: leaves.len() as u64,
uris: Some(vec![chunk_uri(&uri)]),
};
let record = PoeRecord {
v: 1,
merkle: Some(vec![merkle_entry]),
..PoeRecord::default()
};
let record_bytes = encode_record(&record, input.signer)?;
let published = post_publish(
config,
&record_bytes,
&input.quote_id,
input.idempotency_key.as_deref(),
)?;
Ok(PublishMerkleResponse {
id: published.id,
tx_hash: published.tx_hash,
status: published.status,
root: hex::encode(root),
leaf_count: leaves.len() as u64,
ar_uri: uri,
balance_after_usd_micros: published.balance_after_usd_micros,
})
}