diff --git a/ffi/rust/ant-ffi/src/client.rs b/ffi/rust/ant-ffi/src/client.rs index cb5a169..df4eb84 100644 --- a/ffi/rust/ant-ffi/src/client.rs +++ b/ffi/rust/ant-ffi/src/client.rs @@ -615,6 +615,41 @@ impl Client { }) } + /// Same as [`Self::file_upload_public`] but reports live progress to + /// `listener`: the `"encrypting"`, `"quoting"` and `"storing"` upload phases + /// as the file is self-encrypted, quoted and its chunks land on the network. + /// Use this on the wallet-backed (built-in payment) path to drive a progress + /// bar; the external-signer path uses the `prepare`/`finalize_*_with_progress` + /// pair instead. + pub async fn file_upload_public_with_progress( + &self, + path: String, + payment_mode: String, + listener: Box, + ) -> Result { + let mode = parse_payment_mode(&payment_mode) + .map_err(|e| ClientError::InvalidInput { reason: e })?; + let file_path = PathBuf::from(&path); + + let (sender, handle) = upload_progress_bridge(listener); + let result = self + .inner + .file_upload_with_progress(&file_path, mode, Some(sender)) + .await; + // Drop of `sender` inside the call ends the bridge; await it to flush + // any queued progress events before returning either way. + let _ = handle.await; + let result = result?; + + // The data-map chunk is small and stored separately (no progress), + // matching `file_upload_public`. + let address = self.inner.data_map_store(&result.data_map).await?; + + Ok(FilePutPublicResult { + address: hex::encode(address), + }) + } + /// Upload a file from disk privately. Returns the serialized data map (hex) /// rather than publishing it — the caller must keep it to retrieve the file /// later via `dataGetPrivate`. This is the private counterpart of @@ -640,6 +675,37 @@ impl Client { }) } + /// Same as [`Self::file_upload_private`] but reports live progress to + /// `listener` (the `"encrypting"`, `"quoting"` and `"storing"` upload + /// phases). See [`Self::file_upload_public_with_progress`]. + pub async fn file_upload_private_with_progress( + &self, + path: String, + payment_mode: String, + listener: Box, + ) -> Result { + let mode = parse_payment_mode(&payment_mode) + .map_err(|e| ClientError::InvalidInput { reason: e })?; + let file_path = PathBuf::from(&path); + + let (sender, handle) = upload_progress_bridge(listener); + let result = self + .inner + .file_upload_with_progress(&file_path, mode, Some(sender)) + .await; + let _ = handle.await; + let result = result?; + + let data_map_bytes = + rmp_serde::to_vec(&result.data_map).map_err(|e| ClientError::InternalError { + reason: format!("failed to serialize data map: {e}"), + })?; + + Ok(FilePutPrivateResult { + data_map: hex::encode(data_map_bytes), + }) + } + /// Publish an existing private data map as a public network chunk, returning /// its hex address. Lets a caller turn a previously-private upload (a hex /// data map from `dataPutPrivate` / `fileUploadPrivate`) into a shareable @@ -732,12 +798,11 @@ impl Client { let address = hex_to_address(&address_hex)?; let data_map = self.inner.data_map_fetch(&address).await?; let dest = PathBuf::from(&dest_path); - self.inner - .file_download(&data_map, &dest) - .await - .map_err(|e| ClientError::NetworkError { - reason: e.to_string(), - })?; + // Propagate the real error via the `From` mapping + // instead of flattening everything into `NetworkError` — a timeout or an + // out-of-disk-space failure now surfaces as its own `ClientError` + // variant rather than a misleading "network error". + self.inner.file_download(&data_map, &dest).await?; Ok(()) } diff --git a/ffi/rust/ant-ffi/src/lib.rs b/ffi/rust/ant-ffi/src/lib.rs index 6a3e2bb..8e3a0fd 100644 --- a/ffi/rust/ant-ffi/src/lib.rs +++ b/ffi/rust/ant-ffi/src/lib.rs @@ -231,12 +231,15 @@ pub struct TxReceipt { /// `phase` is one of the following strings. Note which methods actually emit /// each phase today: /// -/// - **upload** — `"encrypting"` then `"quoting"`, emitted by -/// `prepare_file_upload_with_progress` while the file is encrypted and -/// quoted, then `"storing"`, emitted by `finalize_upload_with_progress` as -/// chunks land on the network. (The plain `prepare_file_upload` / -/// `prepare_data_upload` and the in-memory `prepare_data_upload` path take -/// no listener, so they surface no encrypt/quote progress.) +/// - **upload** — `"encrypting"`, then `"quoting"`, then `"storing"` as the +/// file is self-encrypted, quoted, and its chunks land on the network. On +/// the wallet-backed path all three phases come from a single +/// `file_upload_public_with_progress` / `file_upload_private_with_progress` +/// call. On the external-signer path they are split across the two steps: +/// `prepare_file_upload_with_progress` emits `"encrypting"`/`"quoting"` and +/// `finalize_upload_with_progress` emits `"storing"`. (The plain +/// `file_upload_*` / `prepare_*` methods take no listener, so they surface +/// no progress.) /// - **download** — `"resolving"` then `"downloading"`, emitted by the /// `download_*_to_file` methods. /// @@ -269,6 +272,10 @@ pub enum ClientError { NetworkError { reason: String }, #[error("Payment error: {reason}")] PaymentError { reason: String }, + #[error("Timeout: {reason}")] + Timeout { reason: String }, + #[error("Insufficient disk space: {reason}")] + InsufficientDiskSpace { reason: String }, #[error("Invalid input: {reason}")] InvalidInput { reason: String }, #[error("Not found: {reason}")] @@ -290,10 +297,23 @@ impl From for ClientError { Error::InvalidData(msg) => ClientError::InvalidInput { reason: msg }, Error::Payment(msg) => ClientError::PaymentError { reason: msg }, Error::Network(msg) => ClientError::NetworkError { reason: msg }, - Error::Timeout(msg) => ClientError::NetworkError { - reason: format!("timeout: {msg}"), - }, + Error::Timeout(msg) => ClientError::Timeout { reason: msg }, + Error::InsufficientDiskSpace(msg) => ClientError::InsufficientDiskSpace { reason: msg }, Error::InsufficientPeers(msg) => ClientError::NetworkError { reason: msg }, + // A full disk during a download comes back as `Io` (core writes + // the destination file); surface it as disk-space rather than a + // generic internal error. Other `Io` kinds fall through below. + Error::Io(e) if e.kind() == std::io::ErrorKind::StorageFull => { + ClientError::InsufficientDiskSpace { + reason: format!("local disk full: {e}"), + } + } + // Note: at the pinned ant-core (0.3.1) a missing record surfaces + // as `InvalidData(..)` -> `InvalidInput` above, so this mapping + // never produces `NotFound`. Upstream ant-client#153 (merged + // 2026-07-20) adds a core `NotFound` variant; its arm lands with + // the 0.4.x pin bump (V2-650 / V2-686). Low balance still + // surfaces as `Payment(..)` pending an upstream variant (V2-601). other => ClientError::InternalError { reason: other.to_string(), }, @@ -309,3 +329,31 @@ pub enum WalletError { #[error("Operation failed: {reason}")] OperationFailed { reason: String }, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn core_io_storage_full_maps_to_insufficient_disk_space() { + let io = std::io::Error::new(std::io::ErrorKind::StorageFull, "No space left on device"); + let err: ClientError = ant_core::data::Error::Io(io).into(); + assert!(matches!( + err, + ClientError::InsufficientDiskSpace { reason } if reason.contains("No space left") + )); + } + + #[test] + fn core_io_other_kinds_stay_internal() { + let io = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"); + let err: ClientError = ant_core::data::Error::Io(io).into(); + assert!(matches!(err, ClientError::InternalError { .. })); + } + + #[test] + fn core_timeout_maps_to_timeout_variant() { + let err: ClientError = ant_core::data::Error::Timeout("slow".into()).into(); + assert!(matches!(err, ClientError::Timeout { reason } if reason == "slow")); + } +} diff --git a/ffi/rust/ant-ffi/src/payments.rs b/ffi/rust/ant-ffi/src/payments.rs index d233aad..dd184e4 100644 --- a/ffi/rust/ant-ffi/src/payments.rs +++ b/ffi/rust/ant-ffi/src/payments.rs @@ -192,7 +192,7 @@ pub async fn wait_for_receipt( } if start.elapsed().as_secs() >= timeout_secs { - return Err(ClientError::NetworkError { + return Err(ClientError::Timeout { reason: format!("timed out waiting for receipt of {tx_hash} after {timeout_secs}s"), }); }