Skip to content

Pay the 3x settlement multiplier on the merkle batch path - #161

Merged
jacderida merged 3 commits into
mainfrom
merkle-payment-parity
Jul 28, 2026
Merged

Pay the 3x settlement multiplier on the merkle batch path#161
jacderida merged 3 commits into
mainfrom
merkle-payment-parity

Conversation

@grumbach

Copy link
Copy Markdown
Contributor

Pay the 3x settlement multiplier on the merkle batch path. Client half of
WithAutonomi/ant-node#188, which carries ADR-0008 and the storer-side
requirement; both must ship on the same train — see Compatibility for why
either order is safe in the meantime.

A chunk stored through a merkle batch earned its node one third of what
the identical chunk earned through a single-node upload, for the same storage and
the same replication.

SingleNodePayment::from_quotes pays the median-priced issuer 3 x price. The
merkle path built its pool commitments straight from
MerklePaymentCandidatePool::to_commitment(), which copies the bare quoted price
into the on-chain payable CandidateNode.amount, so the contract's
median16(amount) x 2^depth settled 1 x median per padded leaf.

The multiplier now goes on the payable amount, which is the field the vault pays
out of, and it is read from the same SINGLE_NODE_PAYMENT_MULTIPLIER the
single-node path uses rather than a second literal 3 — the defect was the two
paths disagreeing about the multiplier, so they now read it from one place. The
signed candidate prices and the pool hash are deliberately untouched: the hash is
the key a storer resolves the on-chain payment record under, and it has to keep
committing to the quotes the nodes actually signed.

Nothing detected this. The storer recomputed its expectation from the same
un-multiplied signed prices, so its paid_amount >= expected check passed, and
MerklePaymentCandidatePool::verify_prices (which would compare on-chain amounts
against signed prices) is called by neither repo.

Second commit: cost estimates now bill merkle uploads for the padded tree.
The vault charges per leaf and the tree rounds up to a power of two, so a
65-chunk batch pays for 128 leaves. Estimating from the raw chunk count was
already wrong, but it erred high and was harmless; at 3x it errs low by up to 2x,
and an under-quote is the harmful direction because a caller sizing its wallet
from the estimate runs dry mid-upload.

Linear issue

Risk tier

  • T0 — docs / tooling / CI / pure UX-output. Repo CI only.
  • T1 — client-only, no network-facing behavior change. CI + prod compat smoke.
  • T2 — node/client logic with behavioral surface, no protocol/format/economics change. Dev testnet + ADR.
  • T3 — protocol / storage format / payments / routing. T2 evidence + adversarial testing.

Client-only code, but it changes what every batch upload pays on-chain, so it is
an economics change rather than T1.

Compatibility

  • Wire: none. No message, proof or quote shape changes; the multiplier lands on the value of an existing on-chain field.
  • Storage: none.
  • API: none. The multiplier alias and the leaf-billing helper are private to their modules.

Client cost changes: batch uploads of 64+ chunks pay roughly 3x more. Stated
plainly because it is the point of the change, not a side effect.

Mixed-version rollout, and the one-week receipts:

Client Storer Result
this PR (3x) on ant-node#188 admitted
this PR (3x) older node (requires 1x) admitted — 3x clears the 1x floor
older client (1x) on #188, receipt stamped before its boundary admitted — the rule it was bought under
older client (1x) on #188, receipt stamped at/after the boundary refused
  • Neither half has to land first, so a partial rollout cannot strand uploads.
    An upgraded client simply overpays against an un-upgraded storer.
  • A merkle receipt stays spendable for a week. ant-node#188 keys its requirement
    off the receipt's own timestamp precisely so receipts already bought at 1x keep
    working until they expire, instead of being refused after the money was spent.
  • The last row is the deliberate cost of enforcing rather than shadowing. Anyone
    pinned to an old client past that boundary needs telling before the train.

Semver impact

  • breaking
  • feature
  • fix

No API change. Flagging for review that the economic impact is large even
though the semver impact is not.

Test evidence

Branched from upstream main at 81848a0900fa9972e9af2d323bf3b49bf8d382aa and
verified the diff touches only the two intended files — no workflow, manifest or
lockfile churn (git diff --name-only against that base returns exactly
ant-core/src/data/client/{file,merkle}.rs).

Every CI job that does not need a chain, run locally at this head:

Job Result
cargo fmt --all -- --check pass
cargo clippy --all-targets --all-features -- -D warnings pass
cargo test --lib --all 422 pass
cargo test -p ant-core --test merkle_unit 4 pass
cargo test -p ant-core --test unit_self_encrypt 16 pass, 2 pre-existing ignored
daemon_integration + node_add_integration 6 + 3 pass
cargo doc --all-features --no-deps pass

Chain-backed, against a local Anvil devnet:

  • e2e_cost_estimate6/6 pass, including test_estimate_matches_actual_cost,
    which compares the quoted estimate against what the upload actually settles.
    That is the test that would have caught the padded-leaf change being wrong in
    either direction.
  • e2e_merkle (35-node testnet) — the run that exercises a real batch paying 3x
    through the vault. Still in flight locally when this PR was opened; it is also
    a required CI job here, so the authoritative result is the check on this PR.

Five new tests: every candidate's payable amount is 3x its quoted price; the
signed prices and the pool hash are unchanged by the multiplier; the median of
the payable amounts equals 3x the median quote (the per-leaf parity invariant);
padded-leaf billing is exact at powers of two and rounds up otherwise, including
the MAX_LEAVES split and the two-leaf minimum; and billing never under-quotes
across 1..2000 chunks.

cargo clippy --all-targets --all-features -- -D warnings clean; cargo fmt
clean; RUSTDOCFLAGS=--deny=warnings cargo doc clean.

Gap, stated plainly: what is still owed for T3 is the cross-version pair —
an old client's 1x upload on a fresh receipt being refused once past
ant-node#188's boundary, and an upgraded client admitted at 3x by a node built
from #188. Both halves are local branches right now, so that matrix needs a
testnet with the two builds facing each other; the single-repo suites above
cannot cover it.

New dependency

none

ADR

Mitigation / rollback

Revert this PR and clients return to paying 1x on the batch path. Safe in either
order with the node side: if this is reverted while #188 is live, batch uploads
are refused once past its boundary, so the two reverts should be paired — or
move MERKLE_PARITY_ENFORCED_FROM_UNIX forward, which restores acceptance
without a client release.

grumbach added 2 commits July 28, 2026 17:01
A chunk stored through a merkle batch earned its node one third of what
the identical chunk earned through a single-node upload. The single-node
path pays the median-priced issuer 3x its quote; the merkle path copied
the bare quoted price into the on-chain payable amount, so the contract's
median16(amount) x 2^depth settled 1x the median per leaf.

Apply the multiplier where the payable amount is built. The signed
candidate prices and the pool hash are untouched: the hash is the key a
storer resolves the on-chain payment record under, and it must keep
committing to the quotes the nodes actually signed.

Batch uploads get ~3x more expensive. The alternative, lowering the
single-node path to 1x, would cut per-chunk revenue network-wide by two
thirds instead.
Two follow-ups to paying the 3x multiplier on the merkle path.

Read the multiplier from the single-node constant instead of a second literal
3. The defect being fixed was the two payment paths disagreeing about the
multiplier, so they now read it from one place.

Bill the estimate for padded leaves. The vault charges median16 x 2^depth per
batch and the tree rounds its leaf count up to a power of two, so a 65-chunk
batch pays for 128 leaves. Estimating from the raw chunk count was already
wrong before this change, but it erred high and was harmless; now that merkle
settles at 3x it errs low, by up to 2x on a non-power-of-two batch. An
under-quote is the harmful direction, because a caller sizing its wallet from
the estimate runs dry mid-upload.

@dirvine dirvine left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed exact head b8c1a35c398aad3be4dc8766cf4d343870000984 alongside WithAutonomi/ant-node#188.

Verdict: changes requested. The 3× payable construction is correct, but the new padded-leaf estimator exposes a deterministic 256n+1 payment-path defect and currently claims an executable path that does not exist.

What is correct

  • pool_commitment_with_payment_multiplier applies the shared client-side 3× constant to on-chain candidate amounts with checked arithmetic.
  • Signed candidate prices and pool_hash remain unchanged.
  • The focused multiplier/hash tests pass, as do the real forced-Merkle upload/download tests against released ant-node 0.15.0.

Blocker: 256n+1 becomes a paid partial failure

merkle_billable_leaves(257) returns 258 and the test says the final singleton is billed as a two-leaf tree (file.rs:3506-3510). The execution path does not do that:

  1. pay_for_merkle_multi_batch partitions with addresses.chunks(MAX_LEAVES) (merkle.rs:647-665), so 257 becomes [256, 1].
  2. The first 256-address sub-batch can settle on-chain.
  3. The one-address remainder is passed unchanged to pay_for_merkle_single_batch; MerkleTree requires at least two leaves, so it fails.
  4. Because earlier payment already succeeded, the function returns a partial result with 256 proofs (merkle.rs:681-699), and the user-facing upload reports the final address failed.

Preflight can leave any arbitrary to_upload length, so this affects exact 257/513/etc. uploads and larger uploads whose already-stored filtering leaves such a count. The external-signer path also does not split/rebalance an oversized set.

Suggested fix: use one shared partitioning routine for execution and estimation, guaranteeing every Merkle partition has 2..=MAX_LEAVES entries. Rebalance singleton remainders (257 → [255, 2]; 513 → [256, 255, 2]) rather than inventing a duplicate leaf. Add regressions for 257, 513, post-preflight 257, and external signing—or explicitly route the singleton through a documented compatible payment path.

Test-evidence correction

The PR body overstates current estimate evidence:

  • e2e_cost_estimate::test_estimate_matches_actual_cost uses Auto mode on files producing about three chunks, so it exercises the Single path, not Merkle padding.
  • Current Merkle E2E uses released ant-node 0.15.0; it proves new-client → old-node compatibility, not new-client → ant-node#188 enforcement.
  • No estimate-versus-actual test covers 65/129/257/300 chunks.

Also rename/reword merkle_per_chunk_settlement_matches_single_node_multiplier: it proves 3× settlement per padded leaf against the Merkle pool median, not equal cost per actual chunk around padding boundaries.

Verification

  • cargo fmt --all -- --check — passed
  • cargo test --lib --all --no-fail-fast422 passed
  • Focused multiplier/hash/estimate tests — 5 passed
  • Current client CI is green except Ubuntu general E2E still pending.

The economic release remains separately gated on @forthebux sign-off and paired cross-version evidence. No approval or merge performed.

@jacderida
jacderida force-pushed the merkle-payment-parity branch 3 times, most recently from 9093311 to 6c2a604 Compare July 28, 2026 14:18

@dirvine dirvine left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed current head 6c2a604cf203da80101b1cb6c0a237193ca746f6 after the follow-up push.

Verdict: not approved yet. The 256n+1 payment defect is fixed: execution, padded-leaf estimation and gas estimation now share the same partitioner; 257 becomes [255, 2]; focused local checks pass (66 Merkle-related library tests, 8 merkle_unit tests, and e2e_merkle compiles).

Remaining gates:

  1. Current CI is not green. Merkle E2E (macos-latest) failed in test_merkle_payment_across_batch_boundary: the new 65-address payment received 15 candidates but requires 16. Ubuntu passed the same job, so this may be harness/network flakiness, but it needs a clean rerun or a deterministic fix before approval. The broad E2E jobs are still pending.
  2. The PR body still states the disproved rollout model (“both must ship on the same train”, “either order is safe”, and “partial rollout cannot strand uploads”). The implementation/ADR now use a client-first sequence, with old clients refused by enforcing nodes after the boundary. Please update the public review record.
  3. API warning: MerkleBatchTooLarge extends the public exhaustive ant_core::data::Error enum. Downstream exhaustive matches can stop compiling. Please confirm this breaking surface is deliberate for the next release, or reuse an existing error shape.

Release remains separately gated on paired new-client → new-node evidence and @forthebux economic sign-off. No merge performed.

Payment split oversized uploads with `addresses.chunks(MAX_LEAVES)`, which
respects the 256-leaf ceiling but not the two-leaf floor: 257 addresses came
out as [256, 1]. The 256-address batch was paid on-chain and the singleton
remainder could not build a tree, so the upload returned a paid partial
failure. Every post-preflight count congruent to 1 modulo 256 hits it (257,
513, 769, ...), and preflight leaves an arbitrary count behind.

Add one partitioning helper, `merkle_batch_sizes`, and route both payment and
cost estimation through it. It borrows one address from the preceding batch
rather than stranding it: 257 becomes [255, 2], 513 becomes [256, 255, 2].
Order is preserved and no address is duplicated or synthesised, so a partition
is a plain in-order cover whose every batch is a buildable 2..=256-leaf tree.

Billable leaves are now summed over those same partitions instead of a second
batching model that could drift from what payment does. The leaf totals are
unchanged (255 pads to 256), but they are now derived rather than restated.

The external-signer path passed its whole address set to
`prepare_merkle_batch_external`, which cannot span transactions: one prepared
batch is one signature and one payment. It now returns a typed
`MerkleBatchTooLarge` before any candidate collection, rather than failing
opaquely in the tree build or silently switching payment model.

Also renames `merkle_per_chunk_settlement_matches_single_node_multiplier`,
which proves 3x settlement per *padded leaf* against the pool median, not
equal cost per actual chunk around padding boundaries.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jacderida
jacderida force-pushed the merkle-payment-parity branch from 6c2a604 to ffb7567 Compare July 28, 2026 15:19

@dirvine dirvine left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Engineering approval at head ffb7567482833b5c92699385b387e6605fb1da73. Verified the 3× payable construction, shared Merkle partition/estimate model, 257→[255,2] fix, external-signer guard, focused tests and current compile/lint checks. CI E2E jobs may finish independently. This is code-review approval only; it is not economic sign-off, release authorisation or merge authorisation.

@jacderida
jacderida merged commit 1294cbf into main Jul 28, 2026
15 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants