From 5607f6fbd8ccf4ab5c093362127af5573b00cdf2 Mon Sep 17 00:00:00 2001 From: Mega Mind <146339422+M3gA-Mind@users.noreply.github.com> Date: Mon, 29 Jun 2026 09:57:35 +0530 Subject: [PATCH] fix(tinyplace): read trading balance from tiny.place settlement RPC (#4218) Co-authored-by: M3gA-Mind --- src/openhuman/tinyplace/manifest.rs | 137 +++++++++++++++++++++++----- src/openhuman/wallet/mod.rs | 3 + src/openhuman/wallet/rpc.rs | 2 +- 3 files changed, 117 insertions(+), 25 deletions(-) diff --git a/src/openhuman/tinyplace/manifest.rs b/src/openhuman/tinyplace/manifest.rs index 5ccd7e51b..76bda2ee1 100644 --- a/src/openhuman/tinyplace/manifest.rs +++ b/src/openhuman/tinyplace/manifest.rs @@ -659,38 +659,68 @@ pub(crate) fn handle_tinyplace_registry_register(params: Map) -> /// render (it falls back to letting the backend reject an underfunded payment). async fn wallet_usdc_balance(address: &str) -> (Option, String) { // The wallet's `balances()` only reports NATIVE assets (SOL/ETH/…), never SPL - // tokens — so query the SPL USDC balance directly from the configured Solana - // cluster's RPC (getTokenAccountsByOwner for the cluster's USDC mint). RPC - // failure → None (UI shows "Unknown"); RPC ok but no token account → 0. - let cluster = crate::openhuman::wallet::solana_cluster(); - let mint = cluster.usdc_mint(); - let rpc_url = cluster.rpc_url(); + // tokens — so query the SPL USDC balance directly via + // `getTokenAccountsByOwner` for the cluster's USDC mint. RPC failure → None + // (UI shows "Unknown"); RPC ok but no token account → 0. + // + // TAURI-RUST / #4202: query tiny.place's own Solana settlement RPC FIRST, not + // the bare public cluster RPC. tiny.place settles on its own RPC — the chain + // where agent token accounts are actually funded — so reading the balance + // from the public `api.mainnet-beta.solana.com` endpoint (which also rate- + // limits / restricts `getTokenAccountsByOwner`) returned None and the confirm + // card showed "Unknown" even with a funded, connected wallet. Use the same + // ordered endpoints the payment path broadcasts through (tiny.place RPC → + // public cluster fallback), so the displayed balance matches the chain the + // payment will actually settle on. + let mint = crate::openhuman::wallet::solana_cluster().usdc_mint(); + let endpoints = crate::openhuman::wallet::tinyplace_solana_rpc_endpoints(); let body = serde_json::json!({ "jsonrpc": "2.0", "id": 1, "method": "getTokenAccountsByOwner", "params": [address, { "mint": mint }, { "encoding": "jsonParsed" }], }); - let json: Value = match reqwest::Client::new() - .post(rpc_url) - .json(&body) - .send() - .await - { - Ok(resp) => match resp.json().await { - Ok(j) => j, + let client = reqwest::Client::new(); + // Try each endpoint in priority order; only advance to the next one on a + // transport / parse / malformed-shape failure (the endpoint couldn't give an + // authoritative answer). A well-formed `/result/value` — including an empty + // array (no token account = a real zero balance) — is authoritative and + // returns immediately. + let mut accounts: Option> = None; + for rpc_url in &endpoints { + // A settlement endpoint may embed a private provider token in its + // path/query; redact to scheme+host before it reaches the logs. + let safe_url = crate::openhuman::wallet::redact_rpc_url(rpc_url); + let json: Value = match client.post(rpc_url).json(&body).send().await { + Ok(resp) => match resp.json().await { + Ok(j) => j, + Err(e) => { + log::warn!("{LOG_PREFIX} usdc balance: rpc parse failed at {safe_url}: {e}"); + continue; + } + }, Err(e) => { - log::warn!("{LOG_PREFIX} usdc balance: rpc parse failed: {e}"); - return (None, address.to_string()); + log::warn!("{LOG_PREFIX} usdc balance: rpc send failed at {safe_url}: {e}"); + continue; + } + }; + match json.pointer("/result/value").and_then(Value::as_array) { + Some(value) => { + accounts = Some(value.clone()); + break; + } + None => { + log::warn!("{LOG_PREFIX} usdc balance: unexpected rpc shape at {safe_url}"); + continue; } - }, - Err(e) => { - log::warn!("{LOG_PREFIX} usdc balance: rpc send failed: {e}"); - return (None, address.to_string()); } - }; - let Some(accounts) = json.pointer("/result/value").and_then(Value::as_array) else { - log::warn!("{LOG_PREFIX} usdc balance: unexpected rpc shape"); + } + let Some(accounts) = accounts else { + log::warn!( + "{LOG_PREFIX} usdc balance: no endpoint returned a usable response \ + (tried {} endpoint(s))", + endpoints.len() + ); return (None, address.to_string()); }; // No token account = the ATA was never created = a real zero balance. @@ -711,7 +741,7 @@ async fn wallet_usdc_balance(address: &str) -> (Option, String) { ), None => ("0".to_string(), "0".to_string(), 6), }; - log::debug!("{LOG_PREFIX} usdc balance for {address}: {formatted} (cluster={cluster:?})"); + log::debug!("{LOG_PREFIX} usdc balance for {address}: {formatted} USDC (mint={mint})"); ( Some(serde_json::json!({ "raw": raw, @@ -5035,6 +5065,65 @@ mod tests { assert_eq!(price_from_params(&p).unwrap().asset, "SOL"); } + /// #4202: the confirm-card balance must be read from tiny.place's own Solana + /// settlement RPC (the chain where agent token accounts are funded), not the + /// bare public cluster RPC. We point `TINYPLACE_SOLANA_RPC_URL` at a mock + /// that answers `getTokenAccountsByOwner`; the balance must come back as + /// `Some` with the parsed USDC amount. Before the fix `wallet_usdc_balance` + /// queried the public cluster directly and ignored this endpoint, so the + /// confirm card showed "Unknown". + #[tokio::test] + async fn wallet_usdc_balance_reads_from_tinyplace_settlement_rpc() { + let _lock = crate::openhuman::config::TEST_ENV_LOCK + .lock() + .unwrap_or_else(|e| e.into_inner()); + + let app = axum::Router::new().route( + "/", + axum::routing::post(|| async { + axum::Json(serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "result": { + "context": { "slot": 1 }, + "value": [{ + "account": { "data": { "parsed": { "info": { "tokenAmount": { + "amount": "12500000", + "decimals": 6, + "uiAmount": 12.5, + "uiAmountString": "12.5" + }}}}} + }] + } + })) + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let url = format!("http://127.0.0.1:{}/", addr.port()); + + unsafe { + std::env::set_var("TINYPLACE_SOLANA_RPC_URL", &url); + } + let (balance, address) = wallet_usdc_balance("OwnerAddr1111111111").await; + unsafe { + std::env::remove_var("TINYPLACE_SOLANA_RPC_URL"); + } + + assert_eq!(address, "OwnerAddr1111111111"); + let balance = balance.expect("balance must resolve from the tiny.place settlement RPC"); + assert_eq!( + balance.get("formatted").and_then(Value::as_str), + Some("12.5") + ); + assert_eq!( + balance.get("assetSymbol").and_then(Value::as_str), + Some("USDC") + ); + assert_eq!(balance.get("decimals").and_then(Value::as_u64), Some(6)); + } + // ── GraphQL Feed handler param validation ──────────────────────────────── /// graphql_posts requires `handle`. diff --git a/src/openhuman/wallet/mod.rs b/src/openhuman/wallet/mod.rs index 6710f2c98..e144ac831 100644 --- a/src/openhuman/wallet/mod.rs +++ b/src/openhuman/wallet/mod.rs @@ -40,6 +40,9 @@ pub use ops::{ reveal_recovery_phrase, setup, status, RevealRecoveryPhraseResult, WalletAccount, WalletChain, WalletSetupParams, WalletSetupSource, WalletStatus, WALLET_NOT_CONFIGURED_MESSAGE, }; +/// Reduce an RPC URL to `scheme://host` for logging so private provider tokens +/// embedded in the path/query never reach the logs. +pub(crate) use rpc::redact_rpc_url; pub use schemas::{ all_controller_schemas, all_registered_controllers, all_wallet_controller_schemas, all_wallet_registered_controllers, schemas, wallet_schemas, diff --git a/src/openhuman/wallet/rpc.rs b/src/openhuman/wallet/rpc.rs index cbd5a40a7..2de04a5ba 100644 --- a/src/openhuman/wallet/rpc.rs +++ b/src/openhuman/wallet/rpc.rs @@ -26,7 +26,7 @@ static SHARED_CLIENT: Lazy = Lazy::new(|| { .expect("wallet RPC client builder must succeed with default settings") }); -fn redact_rpc_url(raw: &str) -> String { +pub(crate) fn redact_rpc_url(raw: &str) -> String { match reqwest::Url::parse(raw) { Ok(url) => match url.host_str() { Some(host) => format!("{}://{}", url.scheme(), host),