Skip to content

external-signer finalize: post-payment storage failure strands the payment (PreparedUpload consumed, not retryable) #140

Description

@Nic-dorman

Summary

Client::finalize_upload_with_progress (the external-signer / WalletConnect finalize path) consumes the PreparedUpload by value and drops it on failure. If chunk storage fails after the external wallet has already paid on-chain, the paid attempt is stranded — there is no way to retry storage without paying again, because the original quotes/proofs are gone and cannot be reconstructed.

This is a "pay once, maybe upload once" footgun for the mobile external-signer flow (ant-sdk ant-ffi, WithAutonomi/ant-sdk#199).

Details

ant-core/src/data/client/file.rs:

  • finalize_upload_with_progress(&self, prepared: PreparedUpload, tx_hash_map, progress) takes prepared by value (file.rs:1735).
  • It calls finalize_batch_payment(prepared_chunks, tx_hash_map) to build the paid proofs, then store_paid_chunks_with_events(...) (file.rs:1750-1758).
  • On storage failure it returns Err(Error::PartialUpload { stored, failed, ... }) (file.rs:1759-1778) — it does not return the PreparedUpload or the derived paid_chunks proofs. Both are consumed and lost.
  • PreparedUpload is #[derive(Debug)] #[non_exhaustive] and not Clone (file.rs:1006-1008), so a caller cannot keep a copy across the call.

Why re-prepare does not recover it: PaymentQuote::hash() includes the quote timestamp, node public key, and node signature (evmlib data_payments.rs:101-107, timestamp folded into bytes_for_signing at :111-124). A fresh prepare_* collects new quotes with different quote hashes, so the already-paid {quote_hash: tx_hash} map no longer matches the new prepared upload. The paid material is exactly what gets dropped.

What needs fixing

Make a post-payment storage failure recoverable without re-paying. Options, roughly in order of preference:

  1. Return retry-state on failure. On PartialUpload (and other post-payment store failures), return the retry material — the paid_chunks proofs plus which chunks still need storing — so the caller can re-drive storage against the same payment. e.g. carry a retry: Box<PaidRetryState> in Error::PartialUpload, or return it in the Ok-with-partial shape.
  2. Resumable finalize entry point. Keep the PreparedUpload/proofs owned by the caller (borrow instead of consume, or hand ownership back on error) and expose a finalize_resume(...) that retries only the unstored chunks.
  3. If neither is feasible short-term, document finalize_upload* explicitly as non-retryable after payment so downstreams don't imply otherwise.

Same consideration applies to finalize_upload_merkle_with_progress (file.rs:1839).

Context

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions