Skip to content

antd gRPC FinalizeUpload rejects empty tx_hashes for all-already-stored uploads #233

Description

@JimCollinson

Summary

The released antd v0.11.2 gRPC UploadService.FinalizeUpload rejects an empty tx_hashes map for a valid wave-batch prepare where every chunk is already stored. The equivalent REST path and ant-core accept this no-payment finalization.

The gRPC handler also removes the pending upload before validating the empty map, so the rejected request consumes upload_id and cannot be retried.

Affected release

  • ant-sdk v0.11.2
  • Source commit: 3264b514dac9ed361a7426d6d6d5ae6a8e7b6b15
  • All typed gRPC bindings inherit the daemon behavior.

Expected behavior

When prepare reports payment_type = "wave_batch" with no payments because every chunk is already stored, calling FinalizeUpload with the same upload_id and an empty tx_hashes map should succeed and return the DataMap/address result. No on-chain payment is needed.

This matches:

  • ant-core, where empty external finalization is valid: https://github.com/WithAutonomi/ant-client/blob/3e6bdd28f5af3c7601ca919640bf8dfccf4f8d6b/ant-core/src/data/client/batch.rs#L337-L342
  • REST, which accepts a present empty object ("tx_hashes": {}):
    ant_core::data::ExternalPaymentInfo::WaveBatch { .. } => {
    // Wave-batch: require tx_hashes
    let tx_hashes_raw = req.tx_hashes.ok_or_else(|| {
    AntdError::BadRequest(
    "tx_hashes required for wave-batch upload (this upload used wave_batch payment)"
    .into(),
    )
    })?;
    if req.winner_pool_hash.is_some() {
    return Err(AntdError::BadRequest(
    "winner_pool_hash not applicable for wave-batch upload".into(),
    ));
    }
    // Parse tx_hashes from hex strings
    let tx_hash_map: HashMap<evmlib::common::QuoteHash, evmlib::common::TxHash> =
    tx_hashes_raw
    .iter()
    .map(|(quote_hex, tx_hex)| {
    let quote_bytes: [u8; 32] = hex::decode(quote_hex.trim_start_matches("0x"))
    .map_err(|e| {
    AntdError::BadRequest(format!(
    "invalid quote_hash {quote_hex}: {e}"
    ))
    })?
    .try_into()
    .map_err(|_| {
    AntdError::BadRequest("quote_hash must be 32 bytes".into())
    })?;
    let tx_bytes: [u8; 32] = hex::decode(tx_hex.trim_start_matches("0x"))
    .map_err(|e| {
    AntdError::BadRequest(format!("invalid tx_hash {tx_hex}: {e}"))
    })?
    .try_into()
    .map_err(|_| {
    AntdError::BadRequest("tx_hash must be 32 bytes".into())
    })?;
    Ok((quote_bytes.into(), tx_bytes.into()))
    })
    .collect::<Result<_, AntdError>>()?;
    tokio::spawn(async move {
    let result = client
    .finalize_upload(prepared, &tx_hash_map)
  • The external-signer example guidance recorded in PR feat(antd-js): 07-external-signer example (V2-312, 2/15) #99: “Empty-payments short-circuit ... Daemon accepts empty tx_hashes for finalize.”

Actual behavior

The gRPC handler:

  1. Removes the prepared upload from pending_uploads.
  2. Converts the proto map.
  3. Rejects it when tx_hash_map.is_empty().

Source:

let req = request.into_inner();
let timestamped = self
.state
.pending_uploads
.lock()
.await
.remove(&req.upload_id)
.ok_or_else(|| {
Status::not_found(format!(
"upload_id {} not found — it may have expired or already been finalized",
req.upload_id
))
})?;
let prepared = timestamped.prepared;
let store_on_network = req.store_data_map;
let client = self.state.client.clone();
let (data_map_hex, address, data_map_address, chunks_stored) = match &prepared.payment_info
{
ant_core::data::ExternalPaymentInfo::WaveBatch { .. } => {
if !req.winner_pool_hash.is_empty() {
return Err(Status::invalid_argument(
"winner_pool_hash not applicable for wave-batch upload",
));
}
if req.tx_hashes.is_empty() {
return Err(Status::invalid_argument(
"tx_hashes required for wave-batch upload (this upload used wave_batch payment)",
));
}

The request fails with invalid_argument, and the removed upload_id is no longer available for retry. A repeated/content-deduplicated public upload can therefore prepare successfully over gRPC but fail to finalize and return its DataMap.

Reproduction shape

  1. Prepare and complete a public external-signer upload.
  2. Prepare the identical upload again so all payable chunks are already stored.
  3. Confirm the prepare response is wave_batch with an empty payment collection.
  4. Call gRPC FinalizeUpload using that upload_id and an empty tx_hashes map.
  5. Observe invalid_argument.
  6. Retry with the same upload_id; observe that it has already been consumed.

Suggested fix

  • Allow an empty tx_hashes map for the wave-batch all-already-stored case, matching REST and ant-core.
  • Validate the request shape before removing the pending upload, or reinsert/preserve it on validation failure so invalid requests do not consume retry state.
  • Add a daemon-level gRPC regression test for prepare-all-already-stored → empty-map finalize → successful DataMap result, plus a test proving validation failure does not consume upload_id.

Documentation impact

Until a corrected release ships, developer documentation should warn that gRPC external signing cannot finalize the all-already-stored case and should direct users to REST for that edge case.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions