Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
b5b5252
feat(quote): refuse quotes to clients that cannot settle correctly
grumbach Aug 13, 2026
7205d33
fix(quote): refuse settlement versions this node cannot verify
grumbach Aug 13, 2026
308d1aa
docs(adr): record the settlement-version compatibility policy
grumbach Aug 13, 2026
46db0af
chore(deps): resolve ant-protocol to the branch tip
grumbach Aug 13, 2026
b28fb37
chore: drop the named protocol version from the branch-pin comment
grumbach Aug 13, 2026
06a7aad
docs(adr): correct the downgrade-guard claim and record the ordering …
grumbach Aug 13, 2026
cce94b7
docs(adr): record the mixed-version validation and what it found
grumbach Aug 14, 2026
3d44d6c
fix(payment): recognise a stale client at depths that do not divide e…
grumbach Aug 14, 2026
fc8c1ea
docs(adr): correct the rollout guidance and record the corroboration …
grumbach Aug 14, 2026
c7f9688
docs(adr): record why the probe ceiling must not bind in production
grumbach Aug 14, 2026
0f05cc6
docs(adr): record the release gates that block a fleet-ready verdict
grumbach Aug 14, 2026
ace13ba
test(e2e): drive the settlement gate over a real connection
grumbach Aug 18, 2026
129c556
docs(adr): record the live gate run and narrow the mixed-version gate
grumbach Aug 18, 2026
ce59588
docs(adr): state the rollout order, the watch items and the revert order
grumbach Aug 18, 2026
42266bb
docs(adr): renumber the settlement ADR to 0013
grumbach Aug 26, 2026
c2effd9
fix(quote): count unversioned quote requests on arrival, and say so
grumbach Aug 26, 2026
a055cc0
chore(deps): track the live ant-protocol settlement branch
grumbach Aug 28, 2026
6844f80
docs(e2e): describe the live gate run as done, not as outstanding
grumbach Aug 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,10 @@ mimalloc = "0.1"
# Until then, the git pin tracks the matching saorsa-core lineage
# (the rc-2026.4.2 branch) so Cargo can unify the wire types here
# with ant-protocol's re-exports.
ant-protocol = "2.3.3"
# Branch pin while the settlement-version wire types are in review
# (WithAutonomi/ant-protocol#25). Swap back to a published `ant-protocol`
# version pin once that PR merges and the release train publishes it.
ant-protocol = { git = "https://github.com/grumbach/ant-protocol", branch = "reapply/pr-23-settlement-version" }

# Core (provides EVERYTHING: networking, DHT, security, trust, storage)
saorsa-core = "0.27.1"
Expand Down
234 changes: 234 additions & 0 deletions docs/adr/ADR-0013-settlement-version-and-pre-payment-compatibility.md

Large diffs are not rendered by default.

10 changes: 6 additions & 4 deletions src/ant_protocol/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@
pub use ::ant_protocol::chunk;

pub use ::ant_protocol::chunk::{
ChunkGetRequest, ChunkGetResponse, ChunkMessage, ChunkMessageBody, ChunkPutRequest,
ChunkPutResponse, ChunkQuoteRequest, ChunkQuoteResponse, MerkleCandidateQuoteRequest,
MerkleCandidateQuoteResponse, ProtocolError, XorName, CHUNK_PROTOCOL_ID, CLOSE_GROUP_MAJORITY,
CLOSE_GROUP_SIZE, DATA_TYPE_CHUNK, MAX_CHUNK_SIZE, MAX_WIRE_MESSAGE_SIZE, PROOF_TAG_MERKLE,
settlement_compatibility, ChunkGetRequest, ChunkGetResponse, ChunkMessage, ChunkMessageBody,
ChunkPutRequest, ChunkPutResponse, ChunkQuoteRequest, ChunkQuoteRequestV2, ChunkQuoteResponse,
MerkleCandidateQuoteRequest, MerkleCandidateQuoteRequestV2, MerkleCandidateQuoteResponse,
ProtocolError, SettlementCompatibility, XorName, CHUNK_PROTOCOL_ID, CLOSE_GROUP_MAJORITY,
CLOSE_GROUP_SIZE, CURRENT_SETTLEMENT_VERSION, DATA_TYPE_CHUNK, MAX_CHUNK_SIZE,
MAX_WIRE_MESSAGE_SIZE, MIN_SUPPORTED_SETTLEMENT_VERSION, PROOF_TAG_MERKLE,
PROOF_TAG_SINGLE_NODE, PROTOCOL_VERSION, XORNAME_LEN,
};
131 changes: 130 additions & 1 deletion src/payment/verifier.rs
Original file line number Diff line number Diff line change
Expand Up @@ -354,6 +354,24 @@ fn merkle_required_multiplier(receipt_timestamp: u64, enforced_from: u64) -> u64
/// scaling a 1x result is wrong. The order here — multiply the total, then
/// divide once — is deliberate, and matches the contract, which computes
/// `totalAmount` before splitting it.
/// Does this underpayment look like a client that applied no multiplier at
/// all, as opposed to one that merely paid too little?
///
/// Compares against the **bare** expectation rather than scaling the paid
/// amount up by the required multiplier. [`merkle_expected_per_node`] floors
/// after multiplying, so `expected(m)` is not generally `m * expected(1)`: at
/// median 901 and depth 7 they differ by one wei. A scaled comparison
/// therefore misses genuinely unmultiplied settlements at every depth that
/// does not divide its leaf count evenly, and those are exactly the clients
/// the upgrade advice exists for.
fn underpayment_looks_like_a_stale_client(
paid: Amount,
expected_bare: Amount,
required_multiplier: u64,
) -> bool {
required_multiplier > 1 && paid == expected_bare
}

fn merkle_expected_per_node(
candidate_prices: &[Amount],
depth: u8,
Expand Down Expand Up @@ -3363,11 +3381,36 @@ impl PaymentVerifier {
)));
}
if *paid_amount < expected_per_node {
// An exact `required_multiplier`x shortfall is the signature of
// a client that settles under the superseded rule: it applied
// no multiplier at all. That is not a pricing dispute, it is an
// outdated client, and the person reading this has already paid
// and cannot get it back. Say so, and say what to do about it,
// rather than leaving them with two integers to compare.
//
// This message is the only thing that reaches such a client.
// The settlement-version gate refuses these uploads before they
// pay, but only for clients new enough to declare a version,
// which by construction excludes every client this branch
// catches.
let looks_like_stale_client = underpayment_looks_like_a_stale_client(
*paid_amount,
expected_per_node_bare,
required_multiplier,
);
let advice = if looks_like_stale_client {
" This is exactly the pre-parity settlement amount, so the paying client is \
too old to settle correctly. Run `ant update` to upgrade, or reinstall from \
https://github.com/WithAutonomi/ant-client/releases/latest. Note the payment \
that was already made cannot be recovered."
} else {
""
};
return Err(Error::Payment(format!(
"Underpayment for node at index {idx}: paid {paid_amount}, \
expected at least {expected_per_node} \
(median16 formula, depth={}, {required_multiplier}x required for a \
receipt stamped {} vs parity boundary {parity_from})",
receipt stamped {} vs parity boundary {parity_from}).{advice}",
payment_info.depth, payment_info.merkle_payment_timestamp
)));
}
Expand Down Expand Up @@ -3632,6 +3675,39 @@ mod tests {
);
}

/// The same non-linearity that test pins also breaks a naive check for
/// "did this client apply no multiplier".
///
/// Scaling the paid amount up by the multiplier and comparing against the
/// parity expectation asks `49_426 == 49_425` at median 901 depth 7, which
/// is false, so a genuinely unmultiplied settlement is not recognised and
/// the payer never learns their client is too old. Comparing against the
/// bare expectation asks the same arithmetic the expectation was built
/// with, and holds at every depth.
#[test]
fn a_stale_client_is_recognised_where_depth_does_not_divide_its_leaves() {
let prices = prices_with_median(901);
let bare = merkle_expected_per_node(&prices, 7, 1).expect("bare expectation is payable");
let parity = merkle_expected_per_node(&prices, 7, PAID_QUOTE_PAYMENT_MULTIPLIER)
.expect("parity expectation is payable");

// The scaled comparison this replaced would have missed it.
assert_ne!(parity, bare * Amount::from(PAID_QUOTE_PAYMENT_MULTIPLIER));

assert!(
underpayment_looks_like_a_stale_client(bare, bare, PAID_QUOTE_PAYMENT_MULTIPLIER),
"an exactly-1x settlement must be recognised at depth 7"
);
// A merely-cheap payment still is not blamed on the client's version.
assert!(!underpayment_looks_like_a_stale_client(
bare.saturating_sub(Amount::from(1u64)),
bare,
PAID_QUOTE_PAYMENT_MULTIPLIER
));
// And under the legacy regime there is nothing to upgrade away from.
assert!(!underpayment_looks_like_a_stale_client(bare, bare, 1));
}

/// The economic invariant ADR-0008 restores: a merkle **leaf** settles
/// for what the single-node path settles — 3x the median quote — up to
/// the contract's integer division.
Expand Down Expand Up @@ -7040,6 +7116,59 @@ mod tests {
err_msg.contains("Underpayment") && err_msg.contains("3x required"),
"Error should name the required multiplier: {err_msg}"
);
// An exact 1x settlement is an outdated client, not a pricing dispute.
// The payer has already spent money they cannot recover, so the
// rejection has to tell them what to do rather than hand them two
// integers. This is the only message that reaches a client too old to
// declare a settlement version at quote time.
assert!(
err_msg.contains("too old to settle correctly") && err_msg.contains("ant update"),
"Error should tell the user to upgrade: {err_msg}"
);
}

/// The upgrade advice is keyed on an EXACT multiplier shortfall, which is
/// what an unmultiplied settlement looks like. A merely-cheap payment is a
/// different fault and must not be blamed on the client's version, or the
/// advice stops meaning anything.
#[tokio::test]
async fn a_partial_shortfall_is_not_blamed_on_an_outdated_client() {
let verifier = merkle_test_verifier();
let (xorname, tagged_proof, pool_hash, ts) = make_valid_merkle_proof_bytes();

// One wei under parity: short, but not the 1x signature.
let almost = merkle_parity_per_node_depth2().saturating_sub(Amount::from(1u64));
{
let info = evmlib::merkle_payments::OnChainPaymentInfo {
depth: 2,
merkle_payment_timestamp: ts,
paid_node_addresses: vec![
(RewardsAddress::new([0u8; 20]), 0, almost),
(RewardsAddress::new([1u8; 20]), 1, almost),
],
};
verifier.pool_cache.lock().put(pool_hash, info);
}

let err_msg = format!(
"{}",
verifier
.verify_payment(
&xorname,
Some(&tagged_proof),
VerificationContext::ClientPut
)
.await
.expect_err("a short settlement must be refused")
);
assert!(
err_msg.contains("Underpayment"),
"Error should still name the underpayment: {err_msg}"
);
assert!(
!err_msg.contains("ant update"),
"A partial shortfall must not be reported as an outdated client: {err_msg}"
);
}

/// A receipt stamped BEFORE the boundary keeps its 1x price. The money was
Expand Down
Loading
Loading