From f1be2d7ab2e0c58a71e0be4f8d2daf5384920d35 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Wed, 11 Nov 2026 00:19:45 +0300 Subject: [PATCH] feat(bus,web3,module): NativeWalletTokenBalances for SPL token balances (contract 1.15) Add a NativeWalletTokenBalances member returning TokenBalanceInfo rows for the catalogued tokens of each configured account. Today that is the Solana cluster's USDC mint (devnet or mainnet from the catalog), read with getTokenAccountsByOwner (jsonParsed) and summed over every token account the owner holds. A provider failure, or an answer for another mint, yields a zero row marked missing rather than failing the read. Native balances stay in NativeWalletBalances so existing consumers keep one row per network. Co-authored-by: Medulla --- crates/tinywallet-bus/README.md | 6 + crates/tinywallet-bus/src/lib_tests.rs | 34 +++++- crates/tinywallet-bus/src/names/mod.rs | 4 + crates/tinywallet-bus/src/version/mod.rs | 2 +- crates/tinywallet-bus/src/wire/mod.rs | 5 +- crates/tinywallet-bus/src/wire/wallet.rs | 27 +++++ crates/tinywallet-module/src/service/mod.rs | 21 ++++ .../src/service/web3_native_wallet_tests.rs | 13 +++ .../tinywallet-module/tests/lifecycle_e2e.rs | 1 + crates/tinywallet-module/tests/module_e2e.rs | 1 + .../src/crypto/chains/solana/mod.rs | 54 +++++++++ .../src/crypto/execution/mod.rs | 2 +- .../src/crypto/execution/mod_tests.rs | 106 ++++++++++++++++++ .../src/crypto/execution/queries.rs | 60 +++++++++- .../src/crypto/execution/types.rs | 2 +- docs/specs/native-wallet-engine-module.md | 1 + 16 files changed, 330 insertions(+), 9 deletions(-) diff --git a/crates/tinywallet-bus/README.md b/crates/tinywallet-bus/README.md index 505e650e..22e0bb00 100644 --- a/crates/tinywallet-bus/README.md +++ b/crates/tinywallet-bus/README.md @@ -47,6 +47,12 @@ argument schemas for all six wallet, seven Web3 and one x402 tools. The implementation crates keep their `tinytools::Tool` adapters and delegate those declarations here. A pre-move fixture pins the established tool JSON. +Contract 1.15 adds `NativeWalletTokenBalances`: catalogued token balances +(today the cluster's USDC SPL mint on Solana, read with +`getTokenAccountsByOwner` / `jsonParsed` and summed over the owner's token +accounts) as `TokenBalanceInfo` rows, kept apart from the native +`BalanceInfo` rows so existing consumers keep one row per network. + Contract 1.7 adds `Web3ConfigureWithEndpoints` alongside the unchanged `Web3Configure(())` member. Hosts that need configured RPC overrides pass the Solana cluster and overridden endpoint keys, never endpoint URLs. The module diff --git a/crates/tinywallet-bus/src/lib_tests.rs b/crates/tinywallet-bus/src/lib_tests.rs index ca4fb307..82402dce 100644 --- a/crates/tinywallet-bus/src/lib_tests.rs +++ b/crates/tinywallet-bus/src/lib_tests.rs @@ -110,7 +110,7 @@ fn network_request_keeps_legacy_json_and_omits_unset_endpoint_url() { #[test] fn the_contract_accepts_its_own_version_and_newer_minors() { - assert_eq!(CONTRACT_VERSION, (1, 14)); + assert_eq!(CONTRACT_VERSION, (1, 15)); assert!(is_compatible(CONTRACT_VERSION)); assert!(is_compatible((CONTRACT_VERSION.0, CONTRACT_VERSION.1 + 1))); assert!(!is_compatible((CONTRACT_VERSION.0 + 1, 0))); @@ -146,7 +146,7 @@ fn the_method_list_is_sorted_complete_and_free_of_duplicates() { METHODS, "sorted dispatch order, no repeats" ); - assert_eq!(METHODS.len(), 38); + assert_eq!(METHODS.len(), 39); for member in [ names::methods::ATTACH_SIGNATURE, names::methods::BUILD_UNSIGNED, @@ -174,6 +174,7 @@ fn the_method_list_is_sorted_complete_and_free_of_duplicates() { names::methods::NATIVE_WALLET_SUPPORTED_ASSETS, names::methods::NATIVE_WALLET_CHAIN_STATUS, names::methods::NATIVE_WALLET_BALANCES, + names::methods::NATIVE_WALLET_TOKEN_BALANCES, names::methods::NATIVE_WALLET_PREPARE_TRANSFER, names::methods::NATIVE_WALLET_EXECUTE_PREPARED, names::methods::NATIVE_WALLET_TX_STATUS, @@ -284,13 +285,14 @@ fn native_wallet_members_are_additive_and_use_one_typed_argument() { methods::NATIVE_WALLET_SUPPORTED_ASSETS, methods::NATIVE_WALLET_CHAIN_STATUS, methods::NATIVE_WALLET_BALANCES, + methods::NATIVE_WALLET_TOKEN_BALANCES, methods::NATIVE_WALLET_PREPARE_TRANSFER, methods::NATIVE_WALLET_EXECUTE_PREPARED, methods::NATIVE_WALLET_TX_STATUS, methods::NATIVE_WALLET_TX_RECEIPT, methods::NATIVE_WALLET_LOOKUP_TX, ]; - assert_eq!(METHODS.len(), 38); + assert_eq!(METHODS.len(), 39); assert!(native_members.iter().all(|member| METHODS.contains(member))); assert_eq!(HOST_METHODS.len(), 6); assert!(HOST_METHODS.contains(&host_methods::AUTHORIZE_NATIVE_WALLET_EXECUTION)); @@ -415,3 +417,29 @@ fn every_confidential_member_is_a_member() { assert!(CONFIDENTIAL_METHODS.contains(&names::methods::GENERATE_MNEMONIC)); assert!(CONFIDENTIAL_METHODS.contains(&names::methods::DERIVE_MNEMONIC_SEED)); } + +#[cfg(feature = "wire")] +#[test] +fn token_balance_rows_serialize_in_camel_case_with_the_mint() { + use crate::wire; + let row = wire::TokenBalanceInfo { + chain: wire::WalletChain::Solana, + evm_network: None, + address: "Owner".into(), + asset_symbol: "USDC".into(), + token_address: "Mint".into(), + decimals: 6, + raw: "1500000".into(), + formatted: "1.500000".into(), + provider_status: wire::ProviderStatus::Ready, + }; + let value = serde_json::to_value(&row).unwrap(); + assert_eq!(value["tokenAddress"], "Mint"); + assert_eq!(value["assetSymbol"], "USDC"); + assert_eq!(value["providerStatus"], "ready"); + assert!(value.get("evmNetwork").is_none()); + assert_eq!( + serde_json::from_value::(value).unwrap(), + row + ); +} diff --git a/crates/tinywallet-bus/src/names/mod.rs b/crates/tinywallet-bus/src/names/mod.rs index 71ac08b4..2d6acdad 100644 --- a/crates/tinywallet-bus/src/names/mod.rs +++ b/crates/tinywallet-bus/src/names/mod.rs @@ -94,6 +94,9 @@ pub mod methods { pub const NATIVE_WALLET_BALANCES: &str = "NativeWalletBalances"; /// Prepare a native wallet transfer quote. pub const NATIVE_WALLET_PREPARE_TRANSFER: &str = "NativeWalletPrepareTransfer"; + /// Read catalogued token balances (SPL mints on Solana) for the configured + /// accounts. Contract 1.15. + pub const NATIVE_WALLET_TOKEN_BALANCES: &str = "NativeWalletTokenBalances"; /// Execute a prepared native wallet transfer. pub const NATIVE_WALLET_EXECUTE_PREPARED: &str = "NativeWalletExecutePrepared"; /// Read native wallet transaction status. @@ -141,6 +144,7 @@ pub const METHODS: &[&str] = &[ methods::NATIVE_WALLET_NETWORK_DEFAULTS, methods::NATIVE_WALLET_PREPARE_TRANSFER, methods::NATIVE_WALLET_SUPPORTED_ASSETS, + methods::NATIVE_WALLET_TOKEN_BALANCES, methods::NATIVE_WALLET_TX_RECEIPT, methods::NATIVE_WALLET_TX_STATUS, methods::SIGN_MESSAGE, diff --git a/crates/tinywallet-bus/src/version/mod.rs b/crates/tinywallet-bus/src/version/mod.rs index 3c97b461..62fb4fd2 100644 --- a/crates/tinywallet-bus/src/version/mod.rs +++ b/crates/tinywallet-bus/src/version/mod.rs @@ -1,7 +1,7 @@ //! The `TinyWallet` `TinyBus` contract version and its binding rule. /// The wire contract version this crate defines. -pub const CONTRACT_VERSION: (u32, u32) = (1, 14); +pub const CONTRACT_VERSION: (u32, u32) = (1, 15); /// Returns whether a host using [`CONTRACT_VERSION`] can bind to `module`. /// diff --git a/crates/tinywallet-bus/src/wire/mod.rs b/crates/tinywallet-bus/src/wire/mod.rs index f63ee511..abae3c28 100644 --- a/crates/tinywallet-bus/src/wire/mod.rs +++ b/crates/tinywallet-bus/src/wire/mod.rs @@ -62,8 +62,9 @@ pub use wallet::{ NativeWalletExecuteRequest, NativeWalletExecutionIntent, NativeWalletPrepareTransferRequest, NativeWalletServiceHandle, NativeWalletServiceRequest, NativeWalletTransactionRequest, PrepareTransferParams, PreparedKind, PreparedStatus, PreparedTransaction, ProviderStatus, - RpcEndpointKey, RpcSource, SolanaCluster, SupportedAsset, TxLookupInfo, TxReceiptInfo, TxState, - TxStatusInfo, WalletAssetDefinition, WalletChain, WalletNetworkDefaults, + RpcEndpointKey, RpcSource, SolanaCluster, SupportedAsset, TokenBalanceInfo, TxLookupInfo, + TxReceiptInfo, TxState, TxStatusInfo, WalletAssetDefinition, WalletChain, + WalletNetworkDefaults, }; use crate::Chain; diff --git a/crates/tinywallet-bus/src/wire/wallet.rs b/crates/tinywallet-bus/src/wire/wallet.rs index 0dcd1ffc..6a481a46 100644 --- a/crates/tinywallet-bus/src/wire/wallet.rs +++ b/crates/tinywallet-bus/src/wire/wallet.rs @@ -465,6 +465,33 @@ pub struct BalanceInfo { pub provider_status: ProviderStatus, } +/// A token balance row: one catalogued token (an SPL mint on Solana) held by +/// one wallet account. Native balances stay in [`BalanceInfo`]. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct TokenBalanceInfo { + /// Chain family. + pub chain: WalletChain, + /// EVM network for EVM rows. + #[serde(skip_serializing_if = "Option::is_none")] + pub evm_network: Option, + /// Account (owner) address. + pub address: String, + /// Asset symbol from the catalog. + pub asset_symbol: String, + /// Token contract or mint address. + pub token_address: String, + /// Smallest-unit decimal places. + pub decimals: u8, + /// Raw smallest-unit amount, summed over every token account the owner + /// holds for this mint. + pub raw: String, + /// Human-readable amount. + pub formatted: String, + /// Provider state; `missing` rows read as zero. + pub provider_status: ProviderStatus, +} + /// What a prepared transfer moves. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] diff --git a/crates/tinywallet-module/src/service/mod.rs b/crates/tinywallet-module/src/service/mod.rs index cd8f8e66..bbc5e206 100644 --- a/crates/tinywallet-module/src/service/mod.rs +++ b/crates/tinywallet-module/src/service/mod.rs @@ -447,6 +447,26 @@ impl Wallet { .map_err(native_wallet_operation_failed) } + /// Read catalogued token balances (SPL mints on Solana) for the + /// configured accounts. Contract 1.15. + async fn native_wallet_token_balances( + &self, + request: tinywallet_bus::wire::NativeWalletServiceRequest, + ) -> BusResult> { + let active = self.native.read().await; + let Some(active) = active.as_ref() else { + return Err(host_unavailable()); + }; + active + .require_handle(&request.service) + .map_err(native_wallet_operation_failed)?; + active + .engine + .token_balances() + .await + .map_err(native_wallet_operation_failed) + } + /// Validate and store an owner-scoped transfer quote inside the module. async fn native_wallet_prepare_transfer( &self, @@ -1537,6 +1557,7 @@ mod exports { "NativeWalletSupportedAssets", "NativeWalletChainStatus", "NativeWalletBalances", + "NativeWalletTokenBalances", "NativeWalletPrepareTransfer", "NativeWalletExecutePrepared", "NativeWalletTxStatus", diff --git a/crates/tinywallet-module/src/service/web3_native_wallet_tests.rs b/crates/tinywallet-module/src/service/web3_native_wallet_tests.rs index 492c7946..6d7999e4 100644 --- a/crates/tinywallet-module/src/service/web3_native_wallet_tests.rs +++ b/crates/tinywallet-module/src/service/web3_native_wallet_tests.rs @@ -108,6 +108,19 @@ async fn native_wallet_configuration_uses_opaque_endpoint_references_and_invalid .await .unwrap(); assert_eq!(balances.len(), 6); + let tokens = wallet + .native_wallet_token_balances(NativeWalletServiceRequest { + service: old.clone(), + }) + .await + .unwrap(); + // The devnet catalog's USDC mint, read for the Solana account. + assert_eq!(tokens.len(), 1); + assert_eq!(tokens[0].asset_symbol, "USDC"); + assert_eq!( + tokens[0].token_address, + "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU" + ); // A provider read proves the opaque reference is carried to the host // callback while the URL stays inside the host. diff --git a/crates/tinywallet-module/tests/lifecycle_e2e.rs b/crates/tinywallet-module/tests/lifecycle_e2e.rs index 944b3300..8745ffa6 100644 --- a/crates/tinywallet-module/tests/lifecycle_e2e.rs +++ b/crates/tinywallet-module/tests/lifecycle_e2e.rs @@ -82,6 +82,7 @@ const EXPECTED_METHODS: &[&str] = &[ "NativeWalletSupportedAssets", "NativeWalletChainStatus", "NativeWalletBalances", + "NativeWalletTokenBalances", "NativeWalletPrepareTransfer", "NativeWalletExecutePrepared", "NativeWalletTxStatus", diff --git a/crates/tinywallet-module/tests/module_e2e.rs b/crates/tinywallet-module/tests/module_e2e.rs index 3346873e..37892958 100644 --- a/crates/tinywallet-module/tests/module_e2e.rs +++ b/crates/tinywallet-module/tests/module_e2e.rs @@ -61,6 +61,7 @@ const EXPECTED_METHODS: &[&str] = &[ "NativeWalletSupportedAssets", "NativeWalletChainStatus", "NativeWalletBalances", + "NativeWalletTokenBalances", "NativeWalletPrepareTransfer", "NativeWalletExecutePrepared", "NativeWalletTxStatus", diff --git a/crates/tinywallet-web3/src/crypto/chains/solana/mod.rs b/crates/tinywallet-web3/src/crypto/chains/solana/mod.rs index fb26e679..0be29f75 100644 --- a/crates/tinywallet-web3/src/crypto/chains/solana/mod.rs +++ b/crates/tinywallet-web3/src/crypto/chains/solana/mod.rs @@ -79,6 +79,60 @@ pub(crate) async fn native_balance(engine: &WalletEngine, address: &str) -> Resu Ok(u128::from(result.value)) } +/// Balance of one SPL `mint` held by `owner`, in the mint's atomic units. +/// +/// Reads `getTokenAccountsByOwner` filtered to the mint with the `jsonParsed` +/// encoding and sums `tokenAmount.amount` over every token account returned +/// (the associated account and any others). No accounts is a zero balance. +pub(crate) async fn token_balance( + engine: &WalletEngine, + owner: &str, + mint: &str, +) -> Result { + validate_solana_address(owner)?; + validate_solana_address(mint)?; + let result: serde_json::Value = engine + .rpc_call( + WalletChain::Solana, + "getTokenAccountsByOwner", + json!([owner, {"mint": mint}, {"encoding": "jsonParsed"}]), + ) + .await?; + sum_parsed_token_amounts(&result, mint) +} + +/// Sum `tokenAmount.amount` over a `getTokenAccountsByOwner` (`jsonParsed`) +/// result, refusing an account for another mint or an unparsable amount. +pub(crate) fn sum_parsed_token_amounts( + result: &serde_json::Value, + mint: &str, +) -> Result { + let accounts = result + .get("value") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| "getTokenAccountsByOwner returned no account list".to_string())?; + let mut total: u128 = 0; + for entry in accounts { + let info = entry + .pointer("/account/data/parsed/info") + .ok_or_else(|| "getTokenAccountsByOwner returned an unparsed account".to_string())?; + if info.get("mint").and_then(serde_json::Value::as_str) != Some(mint) { + return Err("getTokenAccountsByOwner returned an account for another mint".into()); + } + let amount = info + .pointer("/tokenAmount/amount") + .and_then(serde_json::Value::as_str) + .and_then(|amount| amount.parse::().ok()) + .ok_or_else(|| { + "getTokenAccountsByOwner returned an invalid token amount".to_string() + })?; + total = total + .checked_add(amount) + .ok_or_else(|| "token balance overflow".to_string())?; + } + Ok(total) +} + /// The wallet's Solana public key, as the signer derives it. async fn signer_pubkey(engine: &WalletEngine) -> Result<[u8; 32], String> { let account = engine.signer.derive_account(WalletChain::Solana).await?; diff --git a/crates/tinywallet-web3/src/crypto/execution/mod.rs b/crates/tinywallet-web3/src/crypto/execution/mod.rs index 9e8ed550..e6bef5b8 100644 --- a/crates/tinywallet-web3/src/crypto/execution/mod.rs +++ b/crates/tinywallet-web3/src/crypto/execution/mod.rs @@ -36,7 +36,7 @@ pub(crate) use types::PreparedTransferQuote; pub use types::{ BalanceInfo, ChainStatus, ExecutePreparedParams, ExecutionResult, PrepareTransferParams, PreparedKind, PreparedStatus, PreparedTransaction, ProviderStatus, RawBroadcastResult, - SupportedAsset, TxLookupInfo, TxReceiptInfo, TxState, TxStatusInfo, + SupportedAsset, TokenBalanceInfo, TxLookupInfo, TxReceiptInfo, TxState, TxStatusInfo, }; pub use validate::{hex_to_bytes, hex_to_u128, u128_to_hex}; diff --git a/crates/tinywallet-web3/src/crypto/execution/mod_tests.rs b/crates/tinywallet-web3/src/crypto/execution/mod_tests.rs index 6999d5f5..fb9b08bc 100644 --- a/crates/tinywallet-web3/src/crypto/execution/mod_tests.rs +++ b/crates/tinywallet-web3/src/crypto/execution/mod_tests.rs @@ -893,3 +893,109 @@ async fn an_erc20_transfer_pays_the_contract_zero_with_the_recipient_in_calldata other => panic!("expected an EVM spec, got {other:?}"), } } + +const MAINNET_USDC: &str = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; +const DEVNET_USDC: &str = "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU"; + +fn parsed_token_account(mint: &str, amount: &str) -> Value { + json!({ + "pubkey": "TokenAccount1111111111111111111111111111111", + "account": { + "data": { + "program": "spl-token", + "parsed": { + "type": "account", + "info": { + "mint": mint, + "owner": sample_address(WalletChain::Solana), + "tokenAmount": {"amount": amount, "decimals": 6, "uiAmountString": "x"} + } + } + } + } + }) +} + +#[tokio::test] +async fn token_balances_sum_every_spl_account_of_the_catalog_usdc_mint() { + let rig = Rig::new(); + rig.transport.on_rpc( + "getTokenAccountsByOwner", + json!({ + "context": {"slot": 1}, + "value": [ + parsed_token_account(MAINNET_USDC, "1500000"), + parsed_token_account(MAINNET_USDC, "250000"), + ] + }), + ); + + let rows = rig.engine.token_balances().await.unwrap(); + + assert_eq!(rows.len(), 1, "{rows:?}"); + let usdc = &rows[0]; + assert_eq!(usdc.chain, WalletChain::Solana); + assert_eq!(usdc.asset_symbol, "USDC"); + assert_eq!(usdc.token_address, MAINNET_USDC); + assert_eq!(usdc.address, sample_address(WalletChain::Solana)); + assert_eq!(usdc.raw, "1750000"); + assert_eq!(usdc.formatted, "1.750000"); + assert_eq!(usdc.provider_status, ProviderStatus::Ready); + + let (method, params) = rig + .transport + .rpc_calls() + .into_iter() + .find(|(method, _)| method == "getTokenAccountsByOwner") + .unwrap(); + assert_eq!(method, "getTokenAccountsByOwner"); + assert_eq!(params[0], sample_address(WalletChain::Solana)); + assert_eq!(params[1], json!({"mint": MAINNET_USDC})); + assert_eq!(params[2], json!({"encoding": "jsonParsed"})); +} + +#[tokio::test] +async fn token_balances_use_the_devnet_mint_on_devnet_and_read_no_accounts_as_zero() { + let rig = Rig::new(); + rig.endpoints + .set_cluster(tinywallet_bus::wire::SolanaCluster::Devnet); + rig.transport.on_rpc( + "getTokenAccountsByOwner", + json!({"context": {"slot": 1}, "value": []}), + ); + + let rows = rig.engine.token_balances().await.unwrap(); + + assert_eq!(rows[0].token_address, DEVNET_USDC); + assert_eq!(rows[0].raw, "0"); + assert_eq!(rows[0].provider_status, ProviderStatus::Ready); +} + +#[tokio::test] +async fn token_balances_mark_an_unreadable_or_foreign_mint_answer_missing() { + let rig = Rig::new(); + // Nothing scripted: the read fails. + let rows = rig.engine.token_balances().await.unwrap(); + assert_eq!(rows[0].raw, "0"); + assert_eq!(rows[0].provider_status, ProviderStatus::Missing); + + // An account for another mint is not counted as USDC. + let rig = Rig::new(); + rig.transport.on_rpc( + "getTokenAccountsByOwner", + json!({"value": [parsed_token_account(DEVNET_USDC, "9")]}), + ); + let rows = rig.engine.token_balances().await.unwrap(); + assert_eq!(rows[0].raw, "0"); + assert_eq!(rows[0].provider_status, ProviderStatus::Missing); +} + +#[tokio::test] +async fn token_balances_need_a_configured_wallet() { + let rig = Rig::new(); + rig.accounts.set(Ok(FakeWalletAccounts::unconfigured())); + assert_eq!( + rig.engine.token_balances().await.unwrap_err(), + WALLET_NOT_CONFIGURED_MESSAGE + ); +} diff --git a/crates/tinywallet-web3/src/crypto/execution/queries.rs b/crates/tinywallet-web3/src/crypto/execution/queries.rs index 52a02a39..b80ddb6e 100644 --- a/crates/tinywallet-web3/src/crypto/execution/queries.rs +++ b/crates/tinywallet-web3/src/crypto/execution/queries.rs @@ -12,7 +12,7 @@ use crate::crypto::wallet::{WalletAccount, WalletChain, WalletEngine}; use crate::quote::WALLET_NOT_CONFIGURED_MESSAGE; use super::LOG_PREFIX; -use super::types::{BalanceInfo, ChainStatus, ProviderStatus, SupportedAsset}; +use super::types::{BalanceInfo, ChainStatus, ProviderStatus, SupportedAsset, TokenBalanceInfo}; use super::validate::format_amount; /// EVM networks surfaced as their own native-balance rows. The single derived @@ -180,6 +180,64 @@ impl WalletEngine { Ok(out) } + /// Live balances of the catalogued tokens: today every SPL mint in the + /// Solana catalog for the configured cluster (USDC; devnet or mainnet + /// mint). One row per token, a provider failure yields a zero row marked + /// [`ProviderStatus::Missing`] rather than failing the read. + /// + /// # Errors + /// + /// [`WALLET_NOT_CONFIGURED_MESSAGE`] when the wallet is not set up, or the + /// host's error if the status cannot be read. + pub async fn token_balances(&self) -> Result, String> { + let status = self.accounts.status().await?; + if !status.configured { + return Err(WALLET_NOT_CONFIGURED_MESSAGE.to_string()); + } + let cluster = self.endpoints.solana_cluster(); + let mut out = Vec::new(); + for account in status + .accounts + .iter() + .filter(|account| account.chain == WalletChain::Solana) + { + for asset in asset_catalog(WalletChain::Solana, cluster) { + let Some(mint) = asset.contract_address.clone() else { + continue; + }; + let (raw, provider_status) = match solana::token_balance( + self, + &account.address, + &mint, + ) + .await + { + Ok(balance) => (balance, ProviderStatus::Ready), + Err(error) => { + warn!( + "{LOG_PREFIX} token_balances symbol={} falling back to zero: {error}", + asset.symbol + ); + (0, ProviderStatus::Missing) + } + }; + out.push(TokenBalanceInfo { + chain: WalletChain::Solana, + evm_network: None, + address: account.address.clone(), + asset_symbol: asset.symbol, + token_address: mint, + decimals: asset.decimals, + raw: raw.to_string(), + formatted: format_amount(raw, asset.decimals), + provider_status, + }); + } + } + debug!("{LOG_PREFIX} token_balances returned rows={}", out.len()); + Ok(out) + } + /// Read one native balance, falling back to a zero row on failure. async fn balance_row( &self, diff --git a/crates/tinywallet-web3/src/crypto/execution/types.rs b/crates/tinywallet-web3/src/crypto/execution/types.rs index b1d625b0..0cbd4a2f 100644 --- a/crates/tinywallet-web3/src/crypto/execution/types.rs +++ b/crates/tinywallet-web3/src/crypto/execution/types.rs @@ -7,7 +7,7 @@ use crate::quote::Quoted; pub use tinywallet_bus::wire::{ BalanceInfo, ChainStatus, ExecutePreparedParams, ExecutionResult, PrepareTransferParams, PreparedKind, PreparedStatus, PreparedTransaction, ProviderStatus, SupportedAsset, - TxLookupInfo, TxReceiptInfo, TxState, TxStatusInfo, + TokenBalanceInfo, TxLookupInfo, TxReceiptInfo, TxState, TxStatusInfo, }; /// The owner-bound record held in the implementation's quote store. diff --git a/docs/specs/native-wallet-engine-module.md b/docs/specs/native-wallet-engine-module.md index 87c1b07a..ffc612c7 100644 --- a/docs/specs/native-wallet-engine-module.md +++ b/docs/specs/native-wallet-engine-module.md @@ -23,6 +23,7 @@ The native service exposes the current engine surface through typed requests: - configure and close one opaque, host-owned-user-scoped service handle; - list network defaults and supported assets; - query chain readiness and native balances; +- query catalogued token balances (SPL mints on Solana; contract 1.15); - prepare and execute a transfer; - read transaction status, receipt, and raw transaction by chain and hash.