Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
24 changes: 21 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 ant-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,10 @@ tower-http = { version = "0.6.8", features = ["cors"] }
# under `ant_protocol::{evm, transport, pqc}`. This is the ONE pin for
# those three deps — do not add direct evmlib/saorsa-core/saorsa-pqc
# deps here or the version can skew between ant-client and ant-node.
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" }
xor_name = "5"
self_encryption = "0.36"
futures = "0.3"
Expand Down
17 changes: 17 additions & 0 deletions ant-core/src/data/client/batch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,16 @@ impl Client {
///
/// Returns an error if quote collection or payment construction fails.
pub async fn prepare_chunk_payment(&self, content: Bytes) -> Result<Option<PreparedChunk>> {
// A refusal established by any earlier upload on this client stops this
// one before it quotes. The verdict is about this build, not about one
// operation, so the wave path has to honour it exactly as the
// single-node path does in `pay_for_storage`. Checked here as well as
// at the spend because a prepared chunk is also what the external
// signer is handed, and handing one out is telling a user to pay.
if let Some(refusal) = self.corroborated_settlement_refusal() {
return Err(Error::ClientUpdateRequired(refusal));
}

let address = compute_address(&content);
let data_size = u64::try_from(content.len())
.map_err(|e| Error::InvalidData(format!("content size too large: {e}")))?;
Expand Down Expand Up @@ -422,6 +432,13 @@ impl Client {
return Ok((Vec::new(), "0".to_string(), 0));
}

// Re-checked immediately before the spend, not only at preparation.
// Waves are pipelined, so chunks quoted for wave N+1 while wave N is
// still storing can have been prepared before a refusal landed.
if let Some(refusal) = self.corroborated_settlement_refusal() {
return Err(Error::ClientUpdateRequired(refusal));
}

let wallet = self.require_wallet()?;

// Compute total storage cost from the prepared chunks before paying.
Expand Down
137 changes: 134 additions & 3 deletions ant-core/src/data/client/file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -998,6 +998,56 @@ fn fold_single_wave(
}
}

/// Shape a corroborated settlement refusal that landed on a wave **after** an
/// earlier wave had already paid and stored.
///
/// The refusal is terminal for this build, but by wave two or later it is no
/// longer true that nothing was charged: the single-node path pays each wave
/// before storing it, so the earlier waves' spend has settled on-chain. Bare
/// `ClientUpdateRequired` would report the upload as costing nothing and drop
/// the stored set a resume needs, so the refusal is surfaced as a
/// `PartialUpload` carrying the real spend and the stored chunks, with the
/// storer's upgrade instruction kept in the reason. Every remaining chunk is
/// listed as failed: none of it was quoted, let alone paid for.
#[allow(clippy::too_many_arguments)]
fn settlement_refusal_after_paid_waves(
refusal: &str,
wave_num: usize,
wave_count: usize,
stored_addresses: Vec<[u8; 32]>,
total_stored: usize,
remaining: &[[u8; 32]],
total_chunks: usize,
total_storage: Amount,
total_gas: u128,
) -> Error {
let remaining_count = remaining.len();
let refused_note = format!(
"not quoted: storers refused this client's settlement version at wave \
{wave_num}/{wave_count}"
);
let failed: Vec<([u8; 32], String)> = remaining
.iter()
.map(|addr| (*addr, refused_note.clone()))
.collect();
Error::PartialUpload {
stored: stored_addresses,
stored_count: total_stored,
failed,
failed_count: remaining_count,
total_chunks,
spend: Box::new(PartialUploadSpend {
storage_cost_atto: total_storage.to_string(),
gas_cost_wei: total_gas,
}),
reason: format!(
"storers refused this client's settlement version at wave {wave_num}/{wave_count}: \
the {total_stored} chunk(s) in earlier wave(s) were already paid for and stored, \
and the remaining {remaining_count} chunk(s) were neither quoted nor paid. {refusal}"
),
}
}

/// Check that the spill directory has enough free space for the spilled chunks.
///
/// `file_size` is the source file's byte count. We require
Expand Down Expand Up @@ -3243,9 +3293,9 @@ impl Client {
}
// Fold this wave's result. A quorum shortfall (`PartialUpload`) is
// recoverable and its parts are returned to be recorded here;
// genuinely fatal errors propagate via `?` and abort the file, as in
// genuinely fatal errors abort the file, as in
// `upload_merkle_from_spill`.
let outcome = fold_single_wave(
let outcome = match fold_single_wave(
self.batch_upload_chunks_with_events(
wave_data,
progress,
Expand All @@ -3254,7 +3304,29 @@ impl Client {
resume_key,
)
.await,
)?;
) {
Ok(outcome) => outcome,
// A corroborated settlement refusal is terminal, but on any wave
// after the first it lands after earlier waves have paid and
// stored. Bare, it would report the upload as costing nothing
// and lose the stored set; carry both instead. On the first
// wave nothing has been paid, so the bare refusal — whose
// wording says nothing was charged — is exactly right.
Err(Error::ClientUpdateRequired(refusal)) if wave_idx > 0 => {
return Err(settlement_refusal_after_paid_waves(
&refusal,
wave_num,
wave_count,
stored_addresses,
total_stored,
&addresses[wave_idx * UPLOAD_WAVE_SIZE..],
total_chunks,
total_storage,
total_gas,
));
}
Err(e) => return Err(e),
};

if !outcome.failed.is_empty() {
warn!(
Expand Down Expand Up @@ -4798,6 +4870,65 @@ mod tests {
);
}

/// A settlement refusal on a later wave must not be reported as if nothing
/// was charged: the earlier waves paid before storing. The refusal is
/// reshaped into a `PartialUpload` that carries the real spend, the stored
/// set (for resume), every un-quoted chunk as failed, and the storer's
/// upgrade instruction in the reason.
#[test]
fn settlement_refusal_after_paid_waves_carries_spend_and_upgrade_instruction() {
let refusal = "your client is too old to pay the current storage rate. Run `ant update`";
let stored = vec![[1u8; 32], [2u8; 32]];
let remaining = [[3u8; 32], [4u8; 32], [5u8; 32]];

let err = settlement_refusal_after_paid_waves(
refusal,
2,
3,
stored.clone(),
stored.len(),
&remaining,
5,
Amount::from(700u64),
13,
);

let Error::PartialUpload {
stored: got_stored,
stored_count,
failed,
failed_count,
total_chunks,
spend,
reason,
} = err
else {
panic!("expected PartialUpload, got: {err:?}");
};
assert_eq!(got_stored, stored);
assert_eq!(stored_count, 2);
assert_eq!(failed_count, 3);
assert_eq!(total_chunks, 5);
// Every un-quoted chunk is listed, none of them as "stored".
let failed_addrs: Vec<[u8; 32]> = failed.iter().map(|(a, _)| *a).collect();
assert_eq!(failed_addrs, remaining.to_vec());
assert!(failed.iter().all(|(_, why)| why.contains("not quoted")));
// The spend is what the earlier waves actually paid, not zero.
assert_eq!(spend.storage_cost_atto, "700");
assert_eq!(spend.gas_cost_wei, 13);
// The user learns both facts: earlier waves paid, and how to upgrade.
assert!(reason.contains("wave 2/3"), "reason: {reason}");
assert!(
reason.contains("2 chunk(s) in earlier wave(s) were already paid"),
"reason: {reason}"
);
assert!(
reason.contains("3 chunk(s) were neither quoted nor paid"),
"reason: {reason}"
);
assert!(reason.contains(refusal), "reason: {reason}");
}

#[test]
fn partition_addresses_by_proof_handles_all_or_nothing() {
let a = [5u8; 32];
Expand Down
Loading
Loading