diff --git a/CHANGELOG.md b/CHANGELOG.md index 98137df8..5826adb2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- `data::Error::InsufficientFunds { have, need }` (amounts in atto): a wallet whose payment-token + balance is below the payment total now fails with this instead of a generic + `Error::Payment(String)`, so consumers such as the mobile SDK can offer "top up your wallet" + without matching on message text (#152). It covers every wallet-backed payment path: the + journaled native path that file and data uploads use, the wave-batch and Merkle batch paths, and + single-chunk payment. Nothing has been paid when it is returned, because the wallet checks its + balance before sending. A shortfall after earlier batches of the same upload were paid still + surfaces as `PartialUpload`, so the spend and the stored chunks are still reported, and it is now + kept typed in the new `PartialUpload::cause` field (also set when storing the paid batches + failed too). Code that builds `PartialUpload` or matches it without `..` must add the field. The + CLI's message changes from `payment error: Insufficient tokens to pay for quotes. Have: …` to + `insufficient funds: wallet has … atto, payment needs … atto`. A shortfall of gas (ETH) is not + detected up front and is unchanged. + ## [0.3.12] - 2026-10-22 ### Changed (breaking — `ant-core` browser API) diff --git a/ant-core/src/data/client/adaptive.rs b/ant-core/src/data/client/adaptive.rs index b1666279..993445be 100644 --- a/ant-core/src/data/client/adaptive.rs +++ b/ant-core/src/data/client/adaptive.rs @@ -3122,6 +3122,7 @@ mod tests { gas_cost_wei: 0, }), reason: "r".to_string(), + cause: None, }), ), ]; diff --git a/ant-core/src/data/client/batch.rs b/ant-core/src/data/client/batch.rs index bbebc0da..a456fc93 100644 --- a/ant-core/src/data/client/batch.rs +++ b/ant-core/src/data/client/batch.rs @@ -96,7 +96,7 @@ impl SingleNodeQuotePayment { .pay_for_quotes(quote_payments) .await .map_err(|PayForQuotesError(err, _)| { - Error::Payment(format!("Failed to pay for quotes: {err}")) + Error::from_payment_failure(format!("Failed to pay for quotes: {err}")) })?; let mut result_hashes = Vec::new(); @@ -543,7 +543,7 @@ impl Client { .pay_for_quotes(all_payments) .await .map_err(|PayForQuotesError(err, _)| { - Error::Payment(format!("Batch payment failed: {err}")) + Error::from_payment_failure(format!("Batch payment failed: {err}")) })?; info!( diff --git a/ant-core/src/data/client/file/native.rs b/ant-core/src/data/client/file/native.rs index 052b7b97..2fe13d4d 100644 --- a/ant-core/src/data/client/file/native.rs +++ b/ant-core/src/data/client/file/native.rs @@ -690,6 +690,7 @@ fn partial_upload_after_fatal( total_chunks, spend: Box::new(spend), reason, + cause: None, } } @@ -1039,6 +1040,7 @@ fn settlement_refusal_after_paid_waves( 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}" ), + cause: None, } } @@ -2262,6 +2264,7 @@ impl Client { gas_cost_wei: 0, }), reason: "finalize_upload: chunk storage failed after retries".into(), + cause: None, }); } let chunks_stored = already_stored_count + wave_result.stored.len(); @@ -3696,6 +3699,7 @@ mod tests { gas_cost_wei: 0, }), reason: "merkle chunk store aborted".into(), + cause: None, }); let outcome = assemble_merkle_finalize_outcome( store_result, @@ -3780,6 +3784,7 @@ mod tests { gas_cost_wei: 0, }), reason: "quorum shortfall".into(), + cause: None, }); let outcome = assemble_merkle_finalize_outcome( first_pass, @@ -4295,6 +4300,7 @@ mod tests { gas_cost_wei: 11, }), reason: "wave store failed after retries".to_string(), + cause: None, }; let outcome = fold_single_wave(Err(err)).unwrap(); @@ -4350,10 +4356,12 @@ mod tests { total_chunks, spend, reason, + cause, } = err else { panic!("expected PartialUpload, got: {err:?}"); }; + assert!(cause.is_none()); assert_eq!(got_stored, stored); assert_eq!(stored_count, 2); assert_eq!(failed_count, 3); @@ -4771,6 +4779,7 @@ impl Client { gas_cost_wei: batch_result.gas_cost_wei, }), reason, + cause: None, }); } diff --git a/ant-core/src/data/client/merkle.rs b/ant-core/src/data/client/merkle.rs index aa806ff8..b433c94f 100644 --- a/ant-core/src/data/client/merkle.rs +++ b/ant-core/src/data/client/merkle.rs @@ -1097,7 +1097,9 @@ impl Client { prepared.merkle_payment_timestamp, ) .await - .map_err(|e| Error::Payment(format!("Merkle batch payment failed: {e}")))?; + .map_err(|e| { + Error::from_payment_failure(format!("Merkle batch payment failed: {e}")) + })?; info!( "Merkle payment succeeded: winner pool {}", diff --git a/ant-core/src/data/client/mod.rs b/ant-core/src/data/client/mod.rs index 2ff67f31..d4abd7c7 100644 --- a/ant-core/src/data/client/mod.rs +++ b/ant-core/src/data/client/mod.rs @@ -294,7 +294,7 @@ mod settlement_refusal_tests { /// network stress these are how transport failures surface) /// - `PartialUpload` -> `NetworkError` (literal capacity signal: some /// chunks could not be stored) -/// - `AlreadyStored`, `Encryption`, `Crypto`, `Payment`, +/// - `AlreadyStored`, `Encryption`, `Crypto`, `Payment`, `InsufficientFunds`, /// `Serialization`, `InvalidData`, `NotFound`, `SignatureVerification`, /// `Config`, `InsufficientDiskSpace`, `CostEstimationInconclusive`, /// `Cancelled` -> `ApplicationError` (would happen on a perfectly @@ -327,6 +327,7 @@ pub(crate) fn classify_error(err: &Error) -> Outcome { | Error::Encryption(_) | Error::Crypto(_) | Error::Payment(_) + | Error::InsufficientFunds { .. } | Error::Serialization(_) | Error::InvalidData(_) // A definitively absent record, reported over a working link — @@ -1105,6 +1106,13 @@ mod tests { ), (Error::Storage("s".to_string()), Outcome::NetworkError), (Error::Payment("p".to_string()), Outcome::ApplicationError), + ( + Error::InsufficientFunds { + have: ant_protocol::evm::Amount::ZERO, + need: ant_protocol::evm::Amount::from(1), + }, + Outcome::ApplicationError, + ), (Error::Protocol("p".to_string()), Outcome::NetworkError), ( Error::InvalidData("d".to_string()), @@ -1156,6 +1164,7 @@ mod tests { gas_cost_wei: 0, }), reason: "r".to_string(), + cause: None, }, Outcome::NetworkError, ), @@ -1284,6 +1293,7 @@ mod tests { | Error::InsufficientPeers(_) | Error::Storage(_) | Error::Payment(_) + | Error::InsufficientFunds { .. } | Error::Protocol(_) | Error::InvalidData(_) | Error::NotFound(_) diff --git a/ant-core/src/data/client/native_payment.rs b/ant-core/src/data/client/native_payment.rs index 3dc8d336..0e4974b3 100644 --- a/ant-core/src/data/client/native_payment.rs +++ b/ant-core/src/data/client/native_payment.rs @@ -165,7 +165,7 @@ async fn execute_once bool + Send + Syn wallet .prepare_payment(request) .await - .map_err(Error::Payment) + .map_err(Error::from_payment_failure) }; let signed = match prepared { Ok(signed) => signed, diff --git a/ant-core/src/data/client/payment.rs b/ant-core/src/data/client/payment.rs index 1cfdcc24..a3692eb6 100644 --- a/ant-core/src/data/client/payment.rs +++ b/ant-core/src/data/client/payment.rs @@ -129,10 +129,11 @@ impl Client { info!("Payment total: {} atto", payment.total_amount()); // 4. Pay on-chain - let tx_hashes = payment - .pay(wallet) - .await - .map_err(|e| Error::Payment(format!("On-chain payment failed: {e}")))?; + let tx_hashes = payment.pay(wallet).await.map_err(|e| match e { + // Already typed by `pay`; wrapping it would demote it to `Payment`. + Error::InsufficientFunds { .. } => e, + e => Error::Payment(format!("On-chain payment failed: {e}")), + })?; info!( "On-chain payment succeeded: {} transactions", diff --git a/ant-core/src/data/client/upload.rs b/ant-core/src/data/client/upload.rs index 9f7bf1f2..b3253cb3 100644 --- a/ant-core/src/data/client/upload.rs +++ b/ant-core/src/data/client/upload.rs @@ -243,6 +243,7 @@ impl Client { failed, spend, reason, + cause, .. }) => { let stored_set = stored.into_iter().collect::>(); @@ -273,6 +274,7 @@ impl Client { total_chunks: original.len(), spend, reason, + cause, }) } Err(error) => Err(error), @@ -290,43 +292,9 @@ impl Client { mode: PaymentMode::Single, ..Default::default() }; - match self - .upload_unique_records_inner(&records, state, adapter, mode, &mut outcome) + self.upload_unique_records_inner(&records, state, adapter, mode, &mut outcome) .await - { - Err(error @ Error::PartialUpload { .. }) => Err(error), - Err(error) - if outcome.amount != Amount::ZERO - || outcome.gas != 0 - || !outcome.addresses.is_empty() => - { - let reason = match error { - Error::ClientUpdateRequired(reason) => format!( - "Further payment refused; reported spend covers earlier settled batches. {reason}" - ), - error => error.to_string(), - }; - let stored = outcome.addresses.iter().copied().collect::>(); - let failed = records - .iter() - .filter(|r| !stored.contains(&r.address)) - .map(|r| (r.address, reason.clone())) - .collect::>(); - Err(Error::PartialUpload { - stored_count: outcome.addresses.len(), - stored: outcome.addresses, - failed_count: failed.len(), - failed, - total_chunks: records.len(), - spend: Box::new(PartialUploadSpend { - storage_cost_atto: outcome.amount.to_string(), - gas_cost_wei: outcome.gas, - }), - reason, - }) - } - result => result, - } + .map_err(|error| after_spend(error, &records, &outcome)) } async fn upload_unique_records_inner( @@ -365,35 +333,7 @@ impl Client { let storage = self .store_upload_merkle(&mut unique, state, adapter, outcome, total) .await; - match (preparation, storage) { - ( - Err(payment_error), - Err(Error::PartialUpload { - stored, - stored_count, - failed, - failed_count, - total_chunks, - spend, - reason, - }), - ) => { - return Err(Error::PartialUpload { - stored, - stored_count, - failed, - failed_count, - total_chunks, - spend, - reason: format!("{payment_error}; {reason}"), - }); - } - (Err(payment_error), Err(storage_error)) => { - return Err(Error::Payment(format!("{payment_error}; {storage_error}"))); - } - (Err(error), Ok(())) | (Ok(()), Err(error)) => return Err(error), - (Ok(()), Ok(())) => {} - } + payment_and_storage(preparation, storage, records, outcome)?; let waves = unique .chunks(super::batch::PAYMENT_WAVE_SIZE) .collect::>(); @@ -547,6 +487,7 @@ impl Client { storage_cost_atto: outcome.amount.to_string(), gas_cost_wei: outcome.gas, }), + cause: None, }); } Ok(std::mem::take(outcome)) @@ -767,6 +708,7 @@ impl Client { reason: fatal.unwrap_or_else(|| { "Merkle storage short of quorum after deferred retries".into() }), + cause: None, }); } records.retain(|r| !merkle.contains_key(&r.address)); @@ -1021,7 +963,9 @@ impl UploadAdapter for MemoryUploadAdapter<'_> { }) .collect::>(); let (transactions, gas) = wallet.pay_for_quotes(payments).await.map_err( - |ant_protocol::evm::PayForQuotesError(error, _)| Error::Payment(error.to_string()), + |ant_protocol::evm::PayForQuotesError(error, _)| { + Error::from_payment_failure(error.to_string()) + }, )?; let amount = plans.iter().try_fold(Amount::ZERO, |sum, plan| { sum.checked_add(plan.payment.total_amount()) @@ -1046,7 +990,7 @@ impl UploadAdapter for MemoryUploadAdapter<'_> { batch.merkle_payment_timestamp, ) .await - .map_err(|e| Error::Payment(e.to_string()))?; + .map_err(|e| Error::from_payment_failure(e.to_string()))?; Ok(MerkleUploadPayment { winner_pool, amount, @@ -1085,6 +1029,101 @@ impl UploadAdapter for MemoryUploadAdapter<'_> { } } +/// Settle a failed payment preparation against the storage of already-paid batches, which runs +/// after it either way so that earlier paid batches are not stranded. +/// +/// A payment error is kept typed as the `cause` of the partial upload it ends in, so a caller can +/// still tell, for example, that the wallet ran short. +fn payment_and_storage( + preparation: Result<()>, + storage: Result<()>, + records: &[UploadRecord], + outcome: &UploadOutcome, +) -> Result<()> { + match (preparation, storage) { + ( + Err(payment_error), + Err(Error::PartialUpload { + stored, + stored_count, + failed, + failed_count, + total_chunks, + spend, + reason, + .. + }), + ) => Err(Error::PartialUpload { + stored, + stored_count, + failed, + failed_count, + total_chunks, + spend, + reason: format!("{payment_error}; {reason}"), + cause: Some(Box::new(payment_error)), + }), + // Storing paid batches only fails with `PartialUpload` today. Should that change, the + // payment error still stays typed. + (Err(payment_error), Err(storage_error)) => Err(partial_upload( + records, + outcome, + format!("{payment_error}; {storage_error}"), + Some(payment_error), + )), + (Err(error), Ok(())) | (Ok(()), Err(error)) => Err(error), + (Ok(()), Ok(())) => Ok(()), + } +} + +/// The error an upload ends with. Once `outcome` records spend or stored chunks, that is a +/// [`Error::PartialUpload`] reporting them, so a resume knows what is already paid for and +/// stored, with `error` kept as its `cause`. A `PartialUpload`, or an error before anything was +/// spent or stored, is returned as it is. +fn after_spend(error: Error, records: &[UploadRecord], outcome: &UploadOutcome) -> Error { + let nothing_spent_or_stored = + outcome.amount == Amount::ZERO && outcome.gas == 0 && outcome.addresses.is_empty(); + if nothing_spent_or_stored || matches!(error, Error::PartialUpload { .. }) { + return error; + } + let reason = match &error { + Error::ClientUpdateRequired(reason) => format!( + "Further payment refused; reported spend covers earlier settled batches. {reason}" + ), + error => error.to_string(), + }; + partial_upload(records, outcome, reason, Some(error)) +} + +/// A [`Error::PartialUpload`] of `records` as `outcome` left them: its stored chunks and spend, +/// with every other record failed for `reason`. +fn partial_upload( + records: &[UploadRecord], + outcome: &UploadOutcome, + reason: String, + cause: Option, +) -> Error { + let stored = outcome.addresses.iter().copied().collect::>(); + let failed = records + .iter() + .filter(|r| !stored.contains(&r.address)) + .map(|r| (r.address, reason.clone())) + .collect::>(); + Error::PartialUpload { + stored_count: outcome.addresses.len(), + stored: outcome.addresses.clone(), + failed_count: failed.len(), + failed, + total_chunks: records.len(), + spend: Box::new(PartialUploadSpend { + storage_cost_atto: outcome.amount.to_string(), + gas_cost_wei: outcome.gas, + }), + reason, + cause: cause.map(Box::new), + } +} + fn merge_stats(total: &mut WaveAggregateStats, next: WaveAggregateStats) { total.chunk_attempts_total = total .chunk_attempts_total @@ -1098,3 +1137,185 @@ fn merge_stats(total: &mut WaveAggregateStats, next: WaveAggregateStats) { *total = total.saturating_add(next); } } + +#[cfg(test)] +mod tests { + use super::*; + + fn records(count: u8) -> Vec { + (0..count) + .map(|i| UploadRecord { + address: [i; 32], + size: 1, + index: usize::from(i), + }) + .collect() + } + + fn shortfall() -> Error { + Error::InsufficientFunds { + have: Amount::from(5), + need: Amount::from(300), + } + } + + /// The first of three records paid for and stored, as when an earlier batch settled. + fn first_batch_settled() -> UploadOutcome { + UploadOutcome { + addresses: vec![[0; 32]], + amount: Amount::from(100), + gas: 7, + ..Default::default() + } + } + + fn assert_caused_by_the_shortfall(error: &Error) { + match error { + Error::PartialUpload { + cause: Some(cause), .. + } => assert!( + matches!( + **cause, + Error::InsufficientFunds { have, need } + if have == Amount::from(5) && need == Amount::from(300) + ), + "cause: {cause:?}" + ), + other => panic!("expected a partial upload caused by the shortfall, got {other:?}"), + } + } + + #[test] + fn a_later_shortfall_with_failed_storage_keeps_what_was_stored_and_the_typed_cause() { + let records = records(3); + let storage = Err(Error::PartialUpload { + stored: vec![[0; 32]], + stored_count: 1, + failed: vec![ + ([1; 32], "short of quorum".into()), + ([2; 32], "upload interrupted before storage".into()), + ], + failed_count: 2, + total_chunks: 3, + spend: Box::new(PartialUploadSpend { + storage_cost_atto: "100".into(), + gas_cost_wei: 7, + }), + reason: "Merkle storage short of quorum after deferred retries".into(), + cause: None, + }); + + let error = + payment_and_storage(Err(shortfall()), storage, &records, &first_batch_settled()) + .unwrap_err(); + + assert_caused_by_the_shortfall(&error); + let Error::PartialUpload { + stored, + failed_count, + spend, + reason, + .. + } = &error + else { + unreachable!() + }; + assert_eq!(stored, &[[0; 32]]); + assert_eq!(*failed_count, 2); + assert_eq!(spend.storage_cost_atto, "100"); + assert_eq!(spend.gas_cost_wei, 7); + assert!( + reason.contains("insufficient funds") && reason.contains("short of quorum"), + "{reason}" + ); + } + + #[test] + fn a_later_shortfall_with_another_storage_error_is_a_partial_upload_with_the_typed_cause() { + let records = records(3); + let storage = Err(Error::Storage("disk full".into())); + + let error = + payment_and_storage(Err(shortfall()), storage, &records, &first_batch_settled()) + .unwrap_err(); + + assert_caused_by_the_shortfall(&error); + let Error::PartialUpload { + stored, + failed, + total_chunks, + spend, + reason, + .. + } = &error + else { + unreachable!() + }; + assert_eq!(stored, &[[0; 32]]); + assert_eq!( + failed + .iter() + .map(|(address, _)| *address) + .collect::>(), + [[1; 32], [2; 32]] + ); + assert_eq!(*total_chunks, 3); + assert_eq!(spend.storage_cost_atto, "100"); + assert!( + reason.contains("insufficient funds") && reason.contains("disk full"), + "{reason}" + ); + } + + #[test] + fn a_shortfall_with_nothing_to_store_is_returned_as_it_is() { + let records = records(3); + let outcome = first_batch_settled(); + assert!(matches!( + payment_and_storage(Err(shortfall()), Ok(()), &records, &outcome), + Err(Error::InsufficientFunds { .. }) + )); + assert!(payment_and_storage(Ok(()), Ok(()), &records, &outcome).is_ok()); + } + + #[test] + fn a_shortfall_after_an_earlier_batch_settled_is_a_partial_upload_caused_by_it() { + let records = records(3); + + let error = after_spend(shortfall(), &records, &first_batch_settled()); + + assert_caused_by_the_shortfall(&error); + let Error::PartialUpload { + stored, + failed_count, + spend, + reason, + .. + } = &error + else { + unreachable!() + }; + assert_eq!(stored, &[[0; 32]]); + assert_eq!(*failed_count, 2); + assert_eq!(spend.storage_cost_atto, "100"); + assert_eq!(reason, &shortfall().to_string()); + } + + #[test] + fn a_shortfall_before_anything_was_spent_stays_insufficient_funds() { + assert!(matches!( + after_spend(shortfall(), &records(3), &UploadOutcome::default()), + Error::InsufficientFunds { .. } + )); + } + + #[test] + fn a_partial_upload_keeps_its_cause_after_spend() { + let records = records(3); + let partial = after_spend(shortfall(), &records, &first_batch_settled()); + + let error = after_spend(partial, &records, &first_batch_settled()); + + assert_caused_by_the_shortfall(&error); + } +} diff --git a/ant-core/src/data/error.rs b/ant-core/src/data/error.rs index 8fea3ece..da2a54da 100644 --- a/ant-core/src/data/error.rs +++ b/ant-core/src/data/error.rs @@ -1,5 +1,6 @@ //! Error types for data operations. +use ant_protocol::evm::Amount; use thiserror::Error; /// Result type alias using the data Error type. @@ -20,6 +21,20 @@ pub enum Error { #[error("payment error: {0}")] Payment(String), + /// The wallet holds fewer payment tokens than the payment needs. + /// + /// Distinct from [`Error::Payment`] so callers can offer "top up your + /// wallet" instead of a generic payment failure. The wallet checks its + /// balance before sending anything, so nothing has been paid. A shortfall + /// of gas (ETH) is not detected up front and still surfaces as `Payment`. + #[error("insufficient funds: wallet has {have} atto, payment needs {need} atto")] + InsufficientFunds { + /// The wallet's payment-token balance, in atto. + have: Amount, + /// The payment total, in atto. + need: Amount, + }, + /// Protocol error. #[error("protocol error: {0}")] Protocol(String), @@ -241,6 +256,12 @@ pub enum Error { spend: Box, /// Root cause description. reason: String, + /// The error that stopped the upload before every chunk was tried, kept + /// typed: for example [`Error::InsufficientFunds`] when the wallet could + /// not pay for a later batch, so a caller can offer a top-up and resume. + /// `None` when the chunks themselves failed to store. `reason` already + /// includes its message. + cause: Option>, }, } @@ -252,6 +273,10 @@ impl Error { Self::Network(m) => Self::Network(m.clone()), Self::Storage(m) => Self::Storage(m.clone()), Self::Payment(m) => Self::Payment(m.clone()), + Self::InsufficientFunds { have, need } => Self::InsufficientFunds { + have: *have, + need: *need, + }, Self::Protocol(m) => Self::Protocol(m.clone()), Self::RemotePut { address, source } => Self::RemotePut { address: address.clone(), @@ -299,6 +324,7 @@ impl Error { total_chunks, spend, reason, + cause, } => Self::PartialUpload { stored: stored.clone(), stored_count: *stored_count, @@ -307,6 +333,7 @@ impl Error { total_chunks: *total_chunks, spend: spend.clone(), reason: reason.clone(), + cause: cause.as_ref().map(|cause| Box::new(cause.duplicate())), }, } } @@ -326,6 +353,36 @@ pub struct PartialUploadSpend { pub gas_cost_wei: u128, } +/// evmlib's wording for a wallet whose token balance is below the payment +/// total (`evmlib::wallet::Error::InsufficientTokensForQuotes`). +/// +/// This text is the only contract available: ant-protocol does not re-export +/// that error type, and evmlib's journaled payment API returns it already +/// rendered as a `String`. `tests/e2e_native_payment.rs` pins it against a real +/// unfunded wallet, so a change of wording in evmlib fails a test rather than +/// silently demoting the error back to [`Error::Payment`]. +const INSUFFICIENT_TOKENS: &str = "Insufficient tokens to pay for quotes. Have: "; + +impl Error { + /// A failed wallet payment: [`Error::InsufficientFunds`] when `message` + /// carries the wallet's balance-shortfall report, else [`Error::Payment`]. + pub(crate) fn from_payment_failure(message: String) -> Self { + insufficient_funds(&message).unwrap_or(Self::Payment(message)) + } +} + +/// Parse evmlib's "Have: {have} atto, need: {need} atto" report out of `message`, +/// wherever it sits in it (callers prefix their own context). +fn insufficient_funds(message: &str) -> Option { + let start = message.find(INSUFFICIENT_TOKENS)? + INSUFFICIENT_TOKENS.len(); + let (have, rest) = message[start..].split_once(" atto, need: ")?; + let (need, _) = rest.split_once(" atto")?; + Some(Error::InsufficientFunds { + have: have.parse().ok()?, + need: need.parse().ok()?, + }) +} + // ant-node is only linked when the `devnet` feature is on, so the // blanket `From` impl follows that gate. LocalDevnet maps node errors // to `Error::Network` via this conversion; default builds never see it. @@ -359,6 +416,56 @@ mod tests { assert_eq!(err.to_string(), "payment error: insufficient funds"); } + #[test] + fn test_display_insufficient_funds() { + let err = Error::InsufficientFunds { + have: Amount::from(5), + need: Amount::from(300), + }; + assert_eq!( + err.to_string(), + "insufficient funds: wallet has 5 atto, payment needs 300 atto" + ); + } + + #[test] + fn payment_failure_with_a_balance_shortfall_is_insufficient_funds() { + // As evmlib renders it, bare (the journaled native path) and under the + // context each caller adds (the batch, merkle and single-node paths). + // `have` exceeds u64 to prove the amounts round-trip as U256. + let report = "Insufficient tokens to pay for quotes. \ + Have: 18446744073709551616 atto, need: 18446744073709551617 atto"; + for message in [ + report.to_string(), + format!("Failed to pay for quotes: {report}"), + format!("Merkle batch payment failed: {report}"), + ] { + match Error::from_payment_failure(message.clone()) { + Error::InsufficientFunds { have, need } => { + assert_eq!(have, Amount::from(u64::MAX) + Amount::from(1)); + assert_eq!(need, Amount::from(u64::MAX) + Amount::from(2)); + } + other => panic!("{message:?} became {other:?}"), + } + } + } + + #[test] + fn other_payment_failures_stay_payment_errors_verbatim() { + for message in [ + "Batch payment failed: server returned an error response: nonce too low", + "insufficient funds for gas * price + value", + // Truncated or malformed reports must not be guessed at. + "Insufficient tokens to pay for quotes. Have: 5 atto", + "Insufficient tokens to pay for quotes. Have: lots atto, need: 300 atto", + ] { + match Error::from_payment_failure(message.to_string()) { + Error::Payment(m) => assert_eq!(m, message), + other => panic!("{message:?} became {other:?}"), + } + } + } + #[test] fn test_display_protocol() { let err = Error::Protocol("invalid message".to_string()); diff --git a/ant-core/tests/e2e_native_payment.rs b/ant-core/tests/e2e_native_payment.rs index fd3c4ea0..e784cd65 100644 --- a/ant-core/tests/e2e_native_payment.rs +++ b/ant-core/tests/e2e_native_payment.rs @@ -82,10 +82,18 @@ async fn insufficient_funds_then_funding_unblocks_native_payment() { PaymentRequest::Quotes(vec![([17; 32].into(), funder.address(), Amount::from(100))]); let adapter = Checkpoints::default(); let mut state = UploadState::default(); - assert!(client + // Also pins evmlib's shortfall wording, which `Error::from_payment_failure` + // parses: if evmlib rewords it, this becomes `Error::Payment` and fails here. + match client .test_execute_native_payment(&adapter, &request, &mut state, true) .await - .is_err()); + { + Err(Error::InsufficientFunds { have, need }) => { + assert_eq!(have, Amount::ZERO); + assert_eq!(need, Amount::from(100)); + } + other => panic!("expected InsufficientFunds, got {:?}", other.map(|_| ())), + } assert!(state.pending_payment.is_none()); assert!(adapter .last