Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions crates/tinywallet-bus/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
34 changes: 31 additions & 3 deletions crates/tinywallet-bus/src/lib_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)));
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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::<wire::TokenBalanceInfo>(value).unwrap(),
row
);
}
4 changes: 4 additions & 0 deletions crates/tinywallet-bus/src/names/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion crates/tinywallet-bus/src/version/mod.rs
Original file line number Diff line number Diff line change
@@ -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`.
///
Expand Down
5 changes: 3 additions & 2 deletions crates/tinywallet-bus/src/wire/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
27 changes: 27 additions & 0 deletions crates/tinywallet-bus/src/wire/wallet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<EvmNetwork>,
/// 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")]
Expand Down
21 changes: 21 additions & 0 deletions crates/tinywallet-module/src/service/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<tinywallet_bus::wire::TokenBalanceInfo>> {
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,
Expand Down Expand Up @@ -1537,6 +1557,7 @@ mod exports {
"NativeWalletSupportedAssets",
"NativeWalletChainStatus",
"NativeWalletBalances",
"NativeWalletTokenBalances",
"NativeWalletPrepareTransfer",
"NativeWalletExecutePrepared",
"NativeWalletTxStatus",
Expand Down
13 changes: 13 additions & 0 deletions crates/tinywallet-module/src/service/web3_native_wallet_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions crates/tinywallet-module/tests/lifecycle_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ const EXPECTED_METHODS: &[&str] = &[
"NativeWalletSupportedAssets",
"NativeWalletChainStatus",
"NativeWalletBalances",
"NativeWalletTokenBalances",
"NativeWalletPrepareTransfer",
"NativeWalletExecutePrepared",
"NativeWalletTxStatus",
Expand Down
1 change: 1 addition & 0 deletions crates/tinywallet-module/tests/module_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ const EXPECTED_METHODS: &[&str] = &[
"NativeWalletSupportedAssets",
"NativeWalletChainStatus",
"NativeWalletBalances",
"NativeWalletTokenBalances",
"NativeWalletPrepareTransfer",
"NativeWalletExecutePrepared",
"NativeWalletTxStatus",
Expand Down
54 changes: 54 additions & 0 deletions crates/tinywallet-web3/src/crypto/chains/solana/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u128, String> {
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<u128, String> {
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::<u128>().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?;
Expand Down
2 changes: 1 addition & 1 deletion crates/tinywallet-web3/src/crypto/execution/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down
Loading
Loading