diff --git a/cmd/crates/soroban-test/tests/it/integration/tx/claimable_balance.rs b/cmd/crates/soroban-test/tests/it/integration/tx/claimable_balance.rs index 4cd4429c1e..74e603b65a 100644 --- a/cmd/crates/soroban-test/tests/it/integration/tx/claimable_balance.rs +++ b/cmd/crates/soroban-test/tests/it/integration/tx/claimable_balance.rs @@ -140,24 +140,21 @@ async fn clawback_claimable_balance() { .assert() .success(); - // Fetch the balance ID from Horizon - let horizon_url = format!( - "http://localhost:8000/claimable_balances/?claimant={}", - claimant - ); - let response = reqwest::get(&horizon_url) - .await - .expect("Failed to fetch claimable balances from Horizon"); - - let json: serde_json::Value = response - .json() - .await - .expect("Failed to parse Horizon response"); - - // Extract the balance ID from the response - let balance_id = json["_embedded"]["records"][0]["id"] - .as_str() - .expect("Failed to get balance ID from Horizon response"); + // Fetch the balance ID from Horizon. Poll instead of reading once: Horizon + // ingestion lags the RPC ack, so an immediate read can find no record yet. + let horizon_url = format!("http://localhost:8000/claimable_balances/?claimant={claimant}"); + let balance_id = crate::integration::util::poll_horizon_until( + &horizon_url, + |json| { + json["_embedded"]["records"][0]["id"] + .as_str() + .map(ToString::to_string) + }, + |_| true, + ) + .await + .expect("claimable balance never appeared on Horizon within the polling window"); + let balance_id = balance_id.as_str(); // Test clawback-claimable-balance command // this should succeed for the issuer diff --git a/cmd/crates/soroban-test/tests/it/integration/tx/clawback.rs b/cmd/crates/soroban-test/tests/it/integration/tx/clawback.rs index 0232415327..f03d50d592 100644 --- a/cmd/crates/soroban-test/tests/it/integration/tx/clawback.rs +++ b/cmd/crates/soroban-test/tests/it/integration/tx/clawback.rs @@ -105,33 +105,30 @@ async fn clawback() { .assert() .success(); - // Verify holder's balance after clawback (should be 500 USDC: 1000 sent - 500 clawed back) - let horizon_url = format!("http://localhost:8000/accounts/{}", holder); - let response = reqwest::get(&horizon_url) - .await - .expect("Failed to fetch account from Horizon"); - let json: serde_json::Value = response - .json() - .await - .expect("Failed to parse Horizon response"); - - let final_balance = json["balances"] - .as_array() - .unwrap() - .iter() - .find(|balance| { - balance["asset_code"].as_str() == Some("USDC") - && balance["asset_issuer"].as_str() == Some(&issuer) - }) - .expect("USDC balance not found after clawback")["balance"] - .as_str() - .unwrap() - .parse::() - .unwrap(); + // Verify holder's balance after clawback (should be 500 USDC: 1000 sent - + // 500 clawed back). Poll instead of reading once: Horizon ingestion lags + // the RPC ack, so an immediate read can still see the pre-clawback balance. + let horizon_url = format!("http://localhost:8000/accounts/{holder}"); + let final_balance = crate::integration::util::poll_horizon_until( + &horizon_url, + |json| { + json["balances"].as_array()?.iter().find(|balance| { + balance["asset_code"].as_str() == Some("USDC") + && balance["asset_issuer"].as_str() == Some(&issuer) + })?["balance"] + .as_str()? + .parse::() + .ok() + }, + |balance| *balance == 500.0, + ) + .await; assert_eq!( - final_balance, 500.0, - "Holder should have 500 USDC remaining after clawback (1000 sent - 500 clawed back)" + final_balance, + Some(500.0), + "Holder should have 500 USDC remaining after clawback (1000 sent - 500 clawed back); \ + last balance observed on Horizon within the polling window shown on the left" ); // Verify that a non-issuer cannot perform clawback diff --git a/cmd/crates/soroban-test/tests/it/integration/util.rs b/cmd/crates/soroban-test/tests/it/integration/util.rs index f2c811c6fd..ce6b4fb5e2 100644 --- a/cmd/crates/soroban-test/tests/it/integration/util.rs +++ b/cmd/crates/soroban-test/tests/it/integration/util.rs @@ -12,6 +12,36 @@ pub const CUSTOM_TYPES: &Wasm = &Wasm::Custom("test-wasms", "test_custom_types") pub const CUSTOM_ACCOUNT: &Wasm = &Wasm::Custom("test-wasms", "test_custom_account"); pub const SWAP: &Wasm = &Wasm::Custom("test-wasms", "test_swap"); +/// Poll a Horizon endpoint until `extract` yields a value that `accept`s, or +/// ~30s elapse (150 tries, 200ms apart). +/// +/// Horizon ingestion lags the RPC acknowledgment the CLI returns on, so a +/// read issued immediately after a submitted transaction can observe stale +/// state. On timeout the last successfully extracted value is returned (even +/// if never accepted) so the caller's assertion message can show what Horizon +/// actually reported instead of a bare `None`. +pub async fn poll_horizon_until( + url: &str, + extract: impl Fn(&serde_json::Value) -> Option, + accept: impl Fn(&T) -> bool, +) -> Option { + let mut last = None; + for _ in 0..150 { + if let Ok(response) = reqwest::get(url).await { + if let Ok(json) = response.json::().await { + if let Some(value) = extract(&json) { + if accept(&value) { + return Some(value); + } + last = Some(value); + } + } + } + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + } + last +} + pub async fn invoke(sandbox: &TestEnv, id: &str, func: &str, data: &str) -> String { sandbox .invoke_with_test(&["--id", id, "--", func, &format!("--{func}"), data])