fix: make wave-batch finalize retryable after payment (#140)#143
fix: make wave-batch finalize retryable after payment (#140)#143Nic-dorman wants to merge 4 commits into
Conversation
Post-payment chunk-store failures in the external-signer finalize path
(`finalize_upload*`) consumed the `PreparedUpload` and dropped the paid
proofs, stranding the on-chain payment: retrying meant paying again,
because fresh quotes carry different quote hashes.
Retain the paid proofs on failure and hand them back so storage can be
re-driven against the same payment:
- `WaveResult` now carries the failed `PaidChunk`s (proofs), not just
their addresses.
- New `Error::FinalizeStorePaidFailed` carries a `PaidRetryState` with
the paid-but-unstored chunks; the wave-batch finalize returns it
instead of `PartialUpload` on a post-payment store failure.
- New `Client::finalize_resume{,_with_progress}` re-drives storage of
the unstored chunks with no re-quote and no second payment; safe to
call repeatedly until it drains.
Merkle finalize stays non-retryable for now and is documented as such
(follow-up).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extend the post-payment retry mechanism to the merkle finalize path, behind the same uniform handle so a consumer catches one error and calls one resume method regardless of payment mode. - `PaidRetryState` now carries a `PaidRetryKind` (Wave | Merkle). The merkle variant holds the reusable `MerkleBatchPaymentResult` (per-chunk proofs, keyed by address) plus the unstored chunk bodies. - `finalize_upload_merkle*` no longer returns a silent `Ok` with `chunks_failed > 0`: a recoverable quorum shortfall now returns `Error::FinalizeStorePaidFailed` with merkle retry state. A fatal (non-quorum) store error stays a hard error, as before. - `finalize_resume` dispatches on the retry kind and re-drives merkle storage against the same batch payment — no new pool, no new winner hash. - Both finalize drivers share a `RetryShared` bundle for their common fields. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Hermes review — PR #143Directionally correct, but I would not merge yet. What looks good:
Blocking gap before merge / taking out of draft:
Checks observed:
Recommendation:
|
Hermes follow-up — Merkle / retry-state issuesA later review pass found two additional issues worth addressing before merge. These do not change the earlier conclusion that the API direction is right, but they tighten the blocker list. Blockers / should-fix
That means an external Merkle payment can still lose the reusable batch proof/body state on a post-payment error path. If the intended contract is “after payment, storage failures retain retry material”, this path should either return retry state as well or be very narrowly classified/documented/tested as truly unrecoverable.
The success path and retryable-failure spend both hard-code Warnings
The earlier test blocker still stands: add behavioural tests for wave and Merkle post-payment failure → retry → success without new payment, plus public DataMap handling if possible. |
Addresses the two blockers, four warnings, and the test gap from the
external review of the finalize-retry work.
Blockers:
- Merkle transient-fatal errors no longer strand the paid batch.
`merkle_upload_chunks` stops re-raising `outcome.fatal`; the new pure
`assemble_merkle_result` classifies it: proof/data-corruption fatals
stay terminal, while transient (network/timeout/io/protocol/storage)
fatals fold every unconfirmed chunk into resumable retry state. The
whole-file data path re-raises fatal itself to keep its contract.
- Merkle external-signer spend is now the real amount. `finalize_merkle_batch`
sets `storage_cost_atto` from the sum of the winner pool's per-node
quoted prices instead of hard-coding "0" (which the FileUploadResult
contract reserves for "nothing to pay").
Warnings:
- Redacted manual `Debug` for `PaidRetryState` — prints counts only, never
chunk bodies or proofs, so `{:?}` logging cannot leak upload material.
- Merkle retry state keeps only the proofs for the unstored subset.
- Documented that resume-call stats are per-call, not lifetime totals.
- Fixed the stale `MerkleStoreOutcome::failed_addresses` doc comment.
Testability + coverage:
- Split both drivers into a thin network call + a pure
`assemble_wave_result` / `assemble_merkle_result`, so the #140 path is
unit-testable without a network.
- Added behavioural round-trip tests proving finalize -> post-payment
store failure -> extract retry state -> resume -> success (no second
payment) for wave and merkle, plus spend/count/data_map_address
assertions, transient-fatal recovery, and unrecoverable-fatal terminality.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review addressed (commit 787a891)Thanks — all blockers, warnings, and the test gap are addressed. Point-by-point: Blockers
Warnings
Test blockerSplit both drivers into a thin network call + a pure
Public-DataMap retrievability end-to-end (address actually fetchable after resume) still belongs in the devnet e2e harness — the unit tests assert the Local: clippy |
Adds an external-signer wave-batch e2e (in-process MiniTestnet) that collapses the network below store quorum *after* payment and asserts: - finalize_upload returns Error::FinalizeStorePaidFailed carrying a PaidRetryState with the paid-but-unstored chunks, - the failed finalize spends no additional tokens, - finalize_resume re-drives the real store/assemble path and, against a still-collapsed network, hands back an equivalent retry state without entering any payment path (wallet balance unchanged throughout). This proves the real store path (not a mocked WaveResult) produces the retry material and that resume never re-pays. Marked #[ignore]: it drives storage against deliberately-killed peers, so its wall-clock is dominated by transport dial timeouts (~6 min, worse on virtualised runners). The deterministic coverage of the same logic runs in CI via the assemble_*_result unit tests; this is an on-demand real-network proof. The success-after-resume half needs node restart, tracked as a devnet follow-up (#144). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Test coverage: Tier 1 landed, Tier 2 filedFollowing up on the reviewer's request for the actual #140 acceptance path: Tier 1 — real-network failure → retry state (added, It's marked Tier 2 — full live round-trip → filed as #144. The success-after-resume half (first attempt fails post-payment, network recovers, Net coverage: unit tests prove failure → resume → success deterministically in CI; Tier 1 proves the real store path produces the retry material and never re-pays; #144 tracks the live success round-trip. |
Summary
Fixes #140. A post-payment chunk-store failure in the external-signer / WalletConnect finalize path (
Client::finalize_upload*) stranded the on-chain payment: the finalize consumed thePreparedUploadand dropped the paid proofs, so retrying storage was impossible without paying again — re-preparing collects fresh quotes with different quote hashes (PaymentQuote::hash()folds in the quote timestamp + node key + signature), so the already-paid{quote_hash: tx_hash}map no longer matches.This makes a post-payment store failure recoverable without re-paying, for both the wave-batch and merkle finalize paths, behind one uniform handle.
What changed
WaveResultretains the paid chunks on failure.store_paid_chunks_with_eventspreviously mapped failures to(address, error)and dropped thePaidChunk(which holdsproof_bytes+ PUT targets). It now also returnsfailed_chunks: Vec<PaidChunk>.Error::FinalizeStorePaidFailedcarries aPaidRetryState— the paid-but-unstored material plus cumulative store progress. The finalize returns this instead ofPartialUpload(wave) / instead of a silent partialOk(merkle) on a post-payment store failure, so "the payment is retained and retryable" is a type-level distinction, not an easily-missed field.PaidRetryStateis one uniform handle over both payment modes (PaidRetryKind::{Wave, Merkle}):PaidChunkproof.MerkleBatchPaymentResult(per-chunk proofs keyed by address) + the unstored chunk bodies. No new pool or winner hash needed to resume.Client::finalize_resume/finalize_resume_with_progressdispatch on the retry kind and re-drive storage of the unstored chunks against the same payment. On success they return the fullFileUploadResult; if some chunks still fail they returnFinalizeStorePaidFailedagain with a reduced retry state, so callers can loop. One catch, one resume, regardless of payment mode.store_paid_wave,store_paid_merkle) share aRetrySharedbundle for their common fields and are used by both the initial finalize and the resume path, so first-attempt and resume behave identically.Behaviour change worth noting for review
finalize_upload_merkle*previously returnedOk(FileUploadResult { chunks_failed: N, .. })on a partial store — a silent partial success. It now returnsError::FinalizeStorePaidFailedwhen chunks remain unstored after retries. A fatal (non-quorum) merkle store error — e.g. a missing proof — is still returned as a hard error, since re-storing can't fix it.PaidRetryStatestays resident in Rust memory (the wave proofs hold non-serializablePeerId/MultiAddr); FFI consumers (ant-ffi, WithAutonomi/ant-sdk#201) retain it as an opaque handle rather than serializing across the boundary.Design decisions worth a look
retry: Option<..>field onPartialUpload. Chose a new variant to keep the retry material non-optional and avoid touching the ~9 otherPartialUploadconstruction sites. Trade-off: external callers that matchedPartialUploadfrom finalize now see a new variant (in-repo nothing does; the only consumer is the FFI, updated in lockstep).PaidRetryState+ onefinalize_resume, vs. separate merkle/wave types and methods. Chose the unified handle for consumer ergonomics (CLAUDE.md principles 2 & 7) — one error, one resume call, mode-agnostic. Internal dispatch absorbs the wave/merkle difference.PaidRetryState, not thePreparedUpload. The latter can't be reconstructed (its payment info is pre-payment); the recoverable state after payment is genuinely a different type.Testing
cargo clippy --all-targets --all-features -- -D warningsclean;cargo fmt --all -- --checkclean.PaidRetryStateaccessors for both wave and merkle kinds, and theFinalizeStorePaidFaileddisplay/retry-material shape.merkle/file/batchmodules: 80 passed.e2e_file.rsalready covers the external-signer happy path) and is not exercised by unit tests here.🤖 Generated with Claude Code