diff --git a/blockrun_llm/solana_client.py b/blockrun_llm/solana_client.py index cd53c13..25d0628 100644 --- a/blockrun_llm/solana_client.py +++ b/blockrun_llm/solana_client.py @@ -365,10 +365,18 @@ def __init__( "Solana payment requires the x402 SDK. " "Install with: pip install blockrun-llm[solana]" ) - key = private_key or os.environ.get("SOLANA_WALLET_KEY") + from .solana_wallet import load_solana_wallet + + key = ( + private_key + or os.environ.get("SOLANA_WALLET_KEY") + or load_solana_wallet() # disk: newest ~/.*/solana-wallet.json, else ~/.blockrun/.solana-session + ) if not key: raise ValueError( - "Private key required. Pass private_key or set SOLANA_WALLET_KEY env var." + "Private key required. Pass private_key, set SOLANA_WALLET_KEY, " + "or have a Solana wallet on disk " + "(~/./solana-wallet.json or ~/.blockrun/.solana-session)." ) self._private_key = key validate_api_url(api_url) @@ -398,7 +406,15 @@ def __init__( # Initialize x402 SDK client for Solana payment signing. self._x402_client = x402ClientSync() - signer = _create_signer(self._private_key) + try: + signer = _create_signer(self._private_key) + except Exception as e: + # Parity with the Base client, which validates the resolved key up + # front: turn a malformed key (incl. one auto-loaded from disk) into + # a clean error instead of a raw base58/solders exception. + raise ValueError( + "Invalid Solana private key (expected a base58-encoded keypair " "or 32-byte seed)." + ) from e _register_svm_with_headers(self._x402_client, signer, resolved_url, resolved_headers) # x402ClientSync is NOT thread-safe: concurrent payment signing on one # shared client races on nonce/authorization state. This lock serializes @@ -1871,10 +1887,18 @@ def __init__( "Solana payment requires the x402 SDK. " "Install with: pip install blockrun-llm[solana]" ) - key = private_key or os.environ.get("SOLANA_WALLET_KEY") + from .solana_wallet import load_solana_wallet + + key = ( + private_key + or os.environ.get("SOLANA_WALLET_KEY") + or load_solana_wallet() # disk: newest ~/.*/solana-wallet.json, else ~/.blockrun/.solana-session + ) if not key: raise ValueError( - "Private key required. Pass private_key or set SOLANA_WALLET_KEY env var." + "Private key required. Pass private_key, set SOLANA_WALLET_KEY, " + "or have a Solana wallet on disk " + "(~/./solana-wallet.json or ~/.blockrun/.solana-session)." ) self._private_key = key validate_api_url(api_url) @@ -1903,7 +1927,15 @@ def __init__( from x402 import x402Client # local import to keep optional dep clean self._x402_client = x402Client() - signer = _create_signer(self._private_key) + try: + signer = _create_signer(self._private_key) + except Exception as e: + # Parity with the Base client, which validates the resolved key up + # front: turn a malformed key (incl. one auto-loaded from disk) into + # a clean error instead of a raw base58/solders exception. + raise ValueError( + "Invalid Solana private key (expected a base58-encoded keypair " "or 32-byte seed)." + ) from e _register_svm_with_headers(self._x402_client, signer, resolved_url, resolved_headers) # Lazily created on first sign (avoids binding asyncio.Lock to a loop at # construction time). Serializes the async signing critical section so a diff --git a/blockrun_llm/solana_wallet.py b/blockrun_llm/solana_wallet.py index d055f93..ee6322a 100644 --- a/blockrun_llm/solana_wallet.py +++ b/blockrun_llm/solana_wallet.py @@ -201,7 +201,10 @@ def load_solana_wallet() -> Optional[str]: # Legacy session file if SOLANA_WALLET_FILE.exists(): - key = SOLANA_WALLET_FILE.read_text().strip() + try: + key = SOLANA_WALLET_FILE.read_text().strip() + except OSError: + return None # unreadable (bad perms/ownership) → treat as "no wallet" if key: return key return None diff --git a/tests/unit/test_solana_client.py b/tests/unit/test_solana_client.py index e2d0b7e..c427c63 100644 --- a/tests/unit/test_solana_client.py +++ b/tests/unit/test_solana_client.py @@ -2,7 +2,7 @@ import pytest import os -from blockrun_llm.solana_client import SolanaLLMClient +from blockrun_llm.solana_client import SolanaLLMClient, AsyncSolanaLLMClient TEST_BS58_KEY = ( "433C7KFcM4y1ZEVdZYSH7wheSNAM384UcbgXEyD5FV7Q2HsQ1BwjEDx4GbBZUqPkZTVhFPyLyuZnzK8wCeAkU7wG" @@ -20,12 +20,41 @@ def test_init_from_env(self): assert client is not None del os.environ["SOLANA_WALLET_KEY"] - def test_raises_without_key(self): - saved = os.environ.pop("SOLANA_WALLET_KEY", None) + def test_raises_without_key(self, monkeypatch): + # No env var AND no wallet session on disk → must still raise. Patch the + # session loader so the test is deterministic regardless of whether the + # machine running it happens to have ~/.blockrun/.solana-session. + monkeypatch.delenv("SOLANA_WALLET_KEY", raising=False) + monkeypatch.setattr("blockrun_llm.solana_wallet.load_solana_wallet", lambda: None) with pytest.raises(ValueError, match="[Pp]rivate key required"): SolanaLLMClient() - if saved: - os.environ["SOLANA_WALLET_KEY"] = saved + + def test_init_from_session_file(self, monkeypatch): + # No env var, but a wallet session exists on disk → auto-load it (parity + # with the Base LLMClient, which already falls back to load_wallet()). + monkeypatch.delenv("SOLANA_WALLET_KEY", raising=False) + monkeypatch.setattr("blockrun_llm.solana_wallet.load_solana_wallet", lambda: TEST_BS58_KEY) + client = SolanaLLMClient() + assert client is not None + + @pytest.mark.asyncio + async def test_async_init_from_session_file(self, monkeypatch): + # Same disk fallback on the async client (identical code path). + monkeypatch.delenv("SOLANA_WALLET_KEY", raising=False) + monkeypatch.setattr("blockrun_llm.solana_wallet.load_solana_wallet", lambda: TEST_BS58_KEY) + client = AsyncSolanaLLMClient() + assert client is not None + await client.close() + + def test_raises_on_invalid_key(self, monkeypatch): + # A malformed key (here from the disk fallback) must surface a clean + # ValueError, not a raw base58/solders exception. Parity with Base. + monkeypatch.delenv("SOLANA_WALLET_KEY", raising=False) + monkeypatch.setattr( + "blockrun_llm.solana_wallet.load_solana_wallet", lambda: "not-a-valid-key" + ) + with pytest.raises(ValueError, match="[Ii]nvalid Solana private key"): + SolanaLLMClient() def test_default_api_url(self): client = SolanaLLMClient(private_key=TEST_BS58_KEY)