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:
- 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.
- 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.
- 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
Summary
Client::finalize_upload_with_progress(the external-signer / WalletConnect finalize path) consumes thePreparedUploadby 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)takespreparedby value (file.rs:1735).finalize_batch_payment(prepared_chunks, tx_hash_map)to build the paid proofs, thenstore_paid_chunks_with_events(...)(file.rs:1750-1758).Err(Error::PartialUpload { stored, failed, ... })(file.rs:1759-1778) — it does not return thePreparedUploador the derivedpaid_chunksproofs. Both are consumed and lost.PreparedUploadis#[derive(Debug)] #[non_exhaustive]and notClone(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 (evmlibdata_payments.rs:101-107, timestamp folded intobytes_for_signingat:111-124). A freshprepare_*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:
PartialUpload(and other post-payment store failures), return the retry material — thepaid_chunksproofs plus which chunks still need storing — so the caller can re-drive storage against the same payment. e.g. carry aretry: Box<PaidRetryState>inError::PartialUpload, or return it in theOk-with-partial shape.PreparedUpload/proofs owned by the caller (borrow instead of consume, or hand ownership back on error) and expose afinalize_resume(...)that retries only the unstored chunks.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