feat(core): multi-batch external merkle signing + spill-backed prepared uploads - #168
Conversation
…d prepared uploads Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ed uploads Implements ADR-0003. The external-signer merkle flow was capped at one payment batch (MAX_LEAVES = 256 chunks ~ 1 GiB, MerkleBatchTooLarge above it) and held every encrypted chunk resident in memory from prepare until finalize — while the wallet path had neither limit (multi-batch payment split, spill-streamed stores). Prepare now partitions the to-upload set with the wallet path's partition rules and returns one PreparedMerkleBatch per sub-batch; chunk bodies stay in the on-disk encryption spill (opaque ExternalChunkStore) instead of a resident Vec<Bytes>. The new finalize_upload_merkle_multi takes one winner hash per batch, folds the paid batches' proofs exactly like pay_for_merkle_multi_batch, and stores through upload_merkle_from_spill — bounded fan-out (~256 MB peak), deferred retry rounds, and PartialUpload accounting shared with the wallet path. A batch the signer never paid (None) no longer aborts the upload: paid batches store, unpaid chunks are reported in PartialUpload's failed set. finalize_upload_merkle stays as the single-batch special case and refuses multi-batch uploads with a pointer to the multi API. Test seams: file_prepare_upload_with_mode exposes the wallet path's mode override externally, and ClientConfig::merkle_external_batch_cap (clamped 3..=MAX_LEAVES — a cap of 2 cannot partition odd totals into payable trees) lets E2E pin small batches, so two new Merkle E2E tests drive a genuine [2, 2] multi-batch flow from a 500 KB public upload: a full N-signature round trip and a pay-1-of-2 run asserting PartialUpload with the paid batch stored. Merkle E2E job budget sized for the two added tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…evnet-gated) Manual ADR-0003 proof at the DEFAULT batch cap: a >1 GiB incompressible file against a LocalDevnet of real ant-node processes — prepare partitions [256, N], a standalone signer wallet pays one tx per batch, finalize stores from the spill, download verifies byte-identical, and a sampler reports peak client RSS to demonstrate the spill-backed prepared upload stays far below file size. Not run in CI. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The helper connected with ClientConfig::default(), whose allow_loopback: false filters every 127.0.0.1 devnet peer — the routing table ended up empty and the first witnessed close-group lookup failed with InsufficientPeers, so the loopback-devnet convenience client could never reach its own devnet. Found by the ADR-0003 real-size harness; the example documents the same requirement for hand-built clients. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MANIFEST=path joins an existing devnet (e.g. ant-devnet --host --serve-port on a LAN box) instead of spawning a LocalDevnet; MODE=download ADDRESS=hex verifies a previous upload from any machine by regenerating the deterministic PRNG stream. Used for the cross-device LAN validation (upload from one machine, byte-verify from another). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
dirvine
left a comment
There was a problem hiding this comment.
Panel review (6 seats: correctness, storage/lifecycle/concurrency, API/compatibility, tests/operations, security, and independent GLM-5.2), against head 4595737c2acd6ef0bd1ab1a4d96e1575a4109e57.
Verdict: no material code blocker found. The panel found the spill ownership/cleanup, bounded read/store concurrency, multi-batch payment/proof merge, and partial-failure accounting coherent. #168 also contains the #167 fix.
One actionable documentation correction:
ant-core/src/data/client/merkle.rs:718sayscapis clamped to2..=MAX_LEAVES, butmerkle_batch_sizes_with_capandClient::merkle_external_batch_capboth correctly clamp to3..=MAX_LEAVES. Please change the public method documentation to3..=MAX_LEAVES.
Non-blocking test gap:
- #168 removes #167's unit test for an all-paid external upload that later falls short of storage quorum. The new E2E coverage exercises a missing/unpaid batch, which is a different path. A focused assertion that quorum shortfall after deferred retries returns
PartialUploadand thatstored + failed == totalwould preserve that regression coverage.
Verification performed:
git diff --check, formatting andcargo check -p ant-core: passed- focused external-Merkle/batch-size tests: 7 passed
- independent seat additionally built
ant-core --testsand the new example, ran Merkle/file unit suites and both new external-Merkle E2Es: passed - current GitHub build, unit, Clippy, format, docs, security and ADR validation: passed; Merkle/general E2E jobs remain pending
Dissent checked: the GLM seat initially reported that the breaking external-signer field changes lacked a changelog entry. That finding is not valid: CHANGELOG.md:10-12 explicitly records prepared_batches and ExternalChunkStore replacing chunk_contents.
Governance note: ADR-0003 is still Proposed; CI confirms its format/immutability rules, but it is not accepted guidance until maintainers record human acceptance.
Doc: prepare_merkle_batches_external's cap doc said 2..=MAX_LEAVES; the clamp (and its floor rationale) is 3..=MAX_LEAVES — aligned with merkle_batch_sizes_with_cap and merkle_external_batch_cap. Test: restore the #167-level regression at the seam the spill path composes — one single-attempt store pass then the deferred rounds — asserting an all-paid quorum shortfall survives every retry with stored + failed == total and the exact shortfall set in failed_addresses (the fold upload_merkle_from_spill turns into Error::PartialUpload). The unpaid-batch E2E covers the other partial path; this pins the paid-but-short one without a network. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…/WithAutonomi/ant-client into feat/merkle-external-multi-batch
|
Both review items addressed in db82913:
The branch also carries the post-#165 lockfile via the main merge, so the Security Audit red is cleared natively on this run. On the governance note: agreed — ADR-0003 stays |
Linear issue
V2-946
Implements ADR-0003. Builds on #167 (its commit is included until it merges; the diff collapses after).
Risk tier
Compatibility
ExternalPaymentInfo::Merklenow carriesprepared_batches: Vec<PreparedMerkleBatch>and an opaque on-diskExternalChunkStore(replacingprepared_batch+ residentchunk_contents: Vec<Bytes>).Client::finalize_upload_merkle_multi(_with_progress)takes oneOption<[u8; 32]>winner hash per batch;finalize_upload_merkleremains as the single-batch special case and now refuses multi-batch uploads with a pointer to the multi API.Client::file_prepare_upload_with_mode(additive);ClientConfiggainsmerkle_external_batch_cap(additive,Defaultcovers it).Semver impact
Test evidence
3..=MAX_LEAVES); per-batch receipt fold (proof union, cost sum, oldest-timestamp expiry semantics); winner-hash validation (count mismatch, all-unpaid, k-of-N mix).test_external_merkle_multi_batch_round_trip— 500 KB public upload, forced merkle, cap 3 → genuine[2, 2]partition → twoWallet::pay_for_merkle_treecalls as the simulated signer →finalize_upload_merkle_multi→ retrieval via the public DataMap address → byte equality.test_external_merkle_partial_payment_is_partial_upload— pay 1 of 2 batches →PartialUpload { stored_count: 2, failed_count: 2 }, proving k-of-N forward progress. Both pass locally against real 35-node testnets + Anvil:2 passed; 0 failedin 182 s total.examples/external-merkle-large.rs, devnet-gated, in this PR): a 1228 MiB incompressible file against a 25-nodeLocalDevnetof real ant-node 0.14.3 processes + Anvil — prepare produced 312 chunks as 2 sub-batches in 12.5 s with 441 MiB RSS (pre-ADR-0003 this held ≥ 1.2 GiB resident through the whole signing window); two on-chain payments (depth 8 → 3 ANT, depth 6 → 0.75 ANT — the 256:64 padded-leaf ratio visible in the settlement); 312/312 chunks stored, 0 failed, in 43 s (peak 1056 MiB during the store fan-out); download via the public DataMap address verified byte-identical; 89 s end-to-end. (Overall process peak 2.7 GiB was in the download/verify phase, which this PR does not touch — noted as a pre-existing observation.)LocalDevnet::create_funded_clientconnected withallow_loopback: false, so the loopback-devnet convenience client could never reach its own devnet (empty routing table →InsufficientPeerson the first witnessed lookup). Fixed in this PR.MANIFESTmode; nodes on a separate Linux box viaant-devnet --nodes 25 --host <lan-ip> --serve-port 8088 --enable-evm, v0.17.0 tag): a 2200 MiB file from a macOS client → 555 chunks as 3 sub-batches ([256, 256, 43→64], depths 8/8/6 — the depth-8 single-tree limit crossed twice), 3 payments (3 + 3 + 0.75 ANT), 555/555 stored, 0 failed in 8.4 min over real QUIC (~9 GiB egress at 4 client-sent copies/chunk), prepare RSS 346 MiB for the 2.2 GiB file. Then a second Linux machine, knowing only the public address and size (harnessMODE=download), fetched the DataMap and byte-verified the full 2.2 GiB in 40 s — cross-device retrievability of a multi-batch external upload.ant-devnet --enable-evm --hostpublishes a manifest whose Anvilrpc_urlis loopback-only (worked around with an SSH tunnel; external signers on other devices can't pay without it).cargo clippy --all-targets --all-features -- -D warningsandcargo fmt --all -- --checkclean.New dependency
none
ADR
ADR-0003: Multi-Batch External Merkle Signing with Spill-Backed Prepared Uploads (in this PR; Proposed — needs human acceptance)
Mitigation / rollback
Single-commit revert restores the single-batch external API (the #166 fix in #167 is a separate commit and survives a revert of this one). No stored data or on-chain state depends on the new shape — a prepared upload is ephemeral session state, so rollback only affects not-yet-finalized sessions.
Notes
PreparedUpload(disk ≈ 1.05× file until finalize/cancel); leaked sessions fall to the existing stale-spill reaper.🤖 Generated with Claude Code