solinpy 0.1.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
solinpy/__init__.py ADDED
File without changes
File without changes
@@ -0,0 +1,190 @@
1
+ import json
2
+ import random
3
+ import time
4
+ import urllib.request
5
+ import urllib.error
6
+ from typing import Optional, Dict, Any
7
+ from solinpy.client.entities import RPCConfig
8
+ from solinpy.client.execptions import RPCError
9
+
10
+ class SolanaRPCClient:
11
+ def __init__(self, config: Optional[RPCConfig | str] = None):
12
+ if isinstance(config, str):
13
+ self.cfg = RPCConfig(custom_endpoint=config)
14
+ else:
15
+ self.cfg = config or RPCConfig()
16
+
17
+ self.endpoint = self.cfg.custom_endpoint or self._resolve_cluster_url()
18
+ self._request_id = 0
19
+
20
+ def _resolve_cluster_url(self) -> str:
21
+ urls = {
22
+ "devnet": "https://api.devnet.solana.com",
23
+ "testnet": "https://api.testnet.solana.com",
24
+ "mainnet": "https://api.mainnet-beta.solana.com",
25
+ }
26
+ return urls.get(self.cfg.cluster, urls["devnet"])
27
+
28
+ def _calc_backoff(self, attempt: int) -> float:
29
+ delay = min(self.cfg.base_delay * (2**attempt), self.cfg.max_delay)
30
+ jitter = random.uniform(0, delay * 0.5)
31
+ return delay + jitter
32
+
33
+ def _is_retryable(self, exc: Exception, rpc_error: Optional[dict] = None) -> bool:
34
+ if isinstance(exc, urllib.error.HTTPError):
35
+ return exc.code in self.cfg.retryable_http_codes
36
+ if isinstance(exc, (urllib.error.URLError, ConnectionError, OSError, TimeoutError)):
37
+ return True
38
+ if rpc_error and rpc_error.get("code") in self.cfg.retryable_rpc_codes:
39
+ return True
40
+ return False
41
+
42
+ def _raise_rpc_error(self, method: str, rpc_error: dict, context: Optional[Dict[str, Any]] = None) -> None:
43
+ raise RPCError.from_rpc_error(method, rpc_error, context=context)
44
+
45
+ def _call(
46
+ self,
47
+ method: str,
48
+ params: Optional[list] = None,
49
+ context: Optional[Dict[str, Any]] = None,
50
+ ) -> Dict[str, Any]:
51
+ self._request_id += 1
52
+ payload = {
53
+ "jsonrpc": "2.0",
54
+ "id": self._request_id,
55
+ "method": method,
56
+ "params": params or [],
57
+ }
58
+ data = json.dumps(payload).encode("utf-8")
59
+
60
+ req = urllib.request.Request(
61
+ self.endpoint, data=data, headers={"Content-Type": "application/json"}, method="POST"
62
+ )
63
+
64
+ last_exc = None
65
+ for attempt in range(self.cfg.max_retries + 1):
66
+ try:
67
+ with urllib.request.urlopen(req, timeout=self.cfg.timeout) as resp:
68
+ body = json.loads(resp.read())
69
+ if "error" in body:
70
+ err = body["error"]
71
+ if self._is_retryable(None, rpc_error=err):
72
+ if attempt < self.cfg.max_retries:
73
+ time.sleep(self._calc_backoff(attempt))
74
+ continue
75
+ self._raise_rpc_error(method, err, context=context)
76
+ return body
77
+
78
+ except urllib.error.HTTPError as e:
79
+ last_exc = e
80
+ if self._is_retryable(e) and attempt < self.cfg.max_retries:
81
+ time.sleep(self._calc_backoff(attempt))
82
+ continue
83
+ raise RPCError.from_transport_error(
84
+ method,
85
+ e,
86
+ context={**(context or {}), "endpoint": self.endpoint},
87
+ message=f"Falha HTTP ao executar {method}. Verifique o endpoint configurado.",
88
+ ) from e
89
+ except (urllib.error.URLError, ConnectionError, OSError, TimeoutError) as e:
90
+ last_exc = e
91
+ if self._is_retryable(e) and attempt < self.cfg.max_retries:
92
+ time.sleep(self._calc_backoff(attempt))
93
+ continue
94
+ break
95
+ except Exception as e:
96
+ last_exc = e
97
+ raise RPCError.from_transport_error(
98
+ method,
99
+ e,
100
+ context={**(context or {}), "endpoint": self.endpoint},
101
+ message=f"Falha inesperada ao executar {method}.",
102
+ ) from e
103
+ raise RPCError(
104
+ f"Falha de comunicação ao executar {method} após {self.cfg.max_retries + 1} tentativas.",
105
+ method=method,
106
+ context={**(context or {}), "endpoint": self.endpoint},
107
+ cause=last_exc,
108
+ ) from last_exc
109
+
110
+ def get_health(self) -> str:
111
+ return self._call("getHealth")["result"]
112
+
113
+ def get_latest_blockhash(self, commitment: str = "confirmed") -> str:
114
+ resp = self._call("getLatestBlockhash", [{"commitment": commitment}], {"commitment": commitment})
115
+ return resp["result"]["value"]["blockhash"]
116
+
117
+ def send_transaction(self, tx_base64: str, max_retries: int = 5) -> str:
118
+ resp = self._call(
119
+ "sendTransaction",
120
+ [tx_base64, {
121
+ "encoding": "base64",
122
+ "maxRetries": max_retries
123
+ }],
124
+ {"tx_size": len(tx_base64), "max_retries": max_retries},
125
+ )
126
+ return resp["result"]
127
+
128
+ def get_balance(self, address: str) -> int:
129
+ """
130
+ Returns the account balance in Lamports.
131
+
132
+ Args:
133
+ address: The base58-encoded public key of the account.
134
+
135
+ Returns:
136
+ int: The balance in Lamports.
137
+ """
138
+ sanitized_address = address.strip()
139
+ resp = self._call("getBalance", [sanitized_address], {"address": sanitized_address})
140
+ return resp["result"]["value"]
141
+
142
+ def get_token_accounts_by_owner(self, address: str) -> list[Dict[str, Any]]:
143
+ """
144
+ Returns the list of SPL token accounts associated with an address.
145
+ """
146
+ TOKEN_PROGRAM_ID = "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA"
147
+
148
+ sanitized_address = address.strip()
149
+
150
+ params = [
151
+ sanitized_address,
152
+ {"programId": TOKEN_PROGRAM_ID},
153
+ {"encoding": "jsonParsed", "commitment": "confirmed"} # Adicionado o commitment
154
+ ]
155
+
156
+ resp = self._call("getTokenAccountsByOwner", params, {"address": sanitized_address})
157
+ return resp["result"]["value"]
158
+
159
+ def get_sol_balance(self, address: str) -> float:
160
+ """
161
+ Returns the account balance converted to SOL (user-friendly unit).
162
+
163
+ Args:
164
+ address: The base58-encoded public key of the account.
165
+
166
+ Returns:
167
+ float: The balance in SOL.
168
+ """
169
+ lamports = self.get_balance(address)
170
+ # 1 SOL is equivalent to 1,000,000,000 Lamports
171
+ return lamports / 1_000_000_000
172
+
173
+ def get_token_balances(self, address: str) -> list[dict]:
174
+ """
175
+ Retrieves a simplified list of token balances for a given address.
176
+ """
177
+ raw_accounts = self.get_token_accounts_by_owner(address)
178
+ balances = []
179
+
180
+ for account in raw_accounts:
181
+ info = account["account"]["data"]["parsed"]["info"]
182
+ token_amount = info["tokenAmount"]
183
+
184
+ balances.append({
185
+ "mint": info["mint"],
186
+ "amount": token_amount["uiAmount"],
187
+ "decimals": token_amount["decimals"]
188
+ })
189
+
190
+ return balances
@@ -0,0 +1,37 @@
1
+ from dataclasses import dataclass, field
2
+ from typing import Optional
3
+
4
+
5
+ @dataclass
6
+ class RPCConfig:
7
+ """Configuração para o cliente RPC Solana."""
8
+ cluster: str = "devnet"
9
+ custom_endpoint: Optional[str] = None
10
+ timeout: float = 10.0
11
+ max_retries: int = 3
12
+ base_delay: float = 1.0
13
+ max_delay: float = 30.0
14
+ retryable_http_codes: tuple[int, ...] = field(
15
+ default_factory=lambda: (429, 500, 502, 503, 504)
16
+ )
17
+ retryable_rpc_codes: tuple[int, ...] = field(default_factory=lambda: (-32004, -32005))
18
+ retries: Optional[int] = None
19
+ backoff_factor: Optional[float] = None
20
+
21
+ def __post_init__(self) -> None:
22
+ if self.retries is not None:
23
+ self.max_retries = self.retries
24
+ else:
25
+ self.retries = self.max_retries
26
+
27
+ if self.backoff_factor is not None:
28
+ self.base_delay = self.backoff_factor
29
+ else:
30
+ self.backoff_factor = self.base_delay
31
+
32
+ @dataclass
33
+ class TokenBalance:
34
+ mint: str
35
+ amount: float
36
+ decimals: int
37
+ ui_amount: str
@@ -0,0 +1,130 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Mapping, Optional
4
+
5
+
6
+ def _format_context(context: Mapping[str, Any] | None) -> str:
7
+ if not context:
8
+ return ""
9
+
10
+ parts = [f"{key}={value}" for key, value in context.items() if value is not None]
11
+ return ", ".join(parts)
12
+
13
+
14
+ def _friendly_rpc_message(method: str, code: Optional[int], message: str) -> str:
15
+ normalized = message.lower()
16
+
17
+ if code == -32600 or "invalid request" in normalized:
18
+ return "Requisição RPC inválida. Verifique os parâmetros enviados."
19
+
20
+ if code == -32601 or "method not found" in normalized:
21
+ return f"Método RPC desconhecido em {method}. Verifique o nome da operação."
22
+
23
+ if code == -32602 or any(
24
+ term in normalized for term in ("invalid params", "invalid parameter", "invalid argument")
25
+ ):
26
+ return f"Parâmetros inválidos ao executar {method}. Revise os valores enviados."
27
+
28
+ if any(term in normalized for term in ("insufficient funds", "insufficient balance")):
29
+ return (
30
+ f"Saldo insuficiente ao executar {method}. "
31
+ "Verifique se a conta tem SOL suficiente para taxas e valor da operação."
32
+ )
33
+
34
+ if any(
35
+ term in normalized
36
+ for term in (
37
+ "account not found",
38
+ "invalid account",
39
+ "could not find account",
40
+ "failed to get account info",
41
+ "account does not exist",
42
+ )
43
+ ):
44
+ return f"Conta inválida ou inexistente ao executar {method}. Verifique o endereço informado."
45
+
46
+ if any(term in normalized for term in ("blockhash not found", "blockhash expired")):
47
+ return f"Blockhash expirado ou inválido ao executar {method}. Busque um blockhash novo e tente novamente."
48
+
49
+ if code in (-32004, -32005):
50
+ return f"Serviço RPC temporariamente indisponível ao executar {method}. Tente novamente em instantes."
51
+
52
+ if message:
53
+ return f"Erro RPC ao executar {method}: {message}"
54
+
55
+ return f"Erro RPC ao executar {method}."
56
+
57
+
58
+ class RPCError(Exception):
59
+ """Erro de RPC com mensagem amigável e contexto estruturado."""
60
+
61
+ def __init__(
62
+ self,
63
+ message: str,
64
+ *,
65
+ code: Optional[int] = None,
66
+ method: Optional[str] = None,
67
+ context: Optional[Mapping[str, Any]] = None,
68
+ cause: Optional[BaseException] = None,
69
+ ) -> None:
70
+ super().__init__(message)
71
+ self.message = message
72
+ self.code = code
73
+ self.method = method
74
+ self.context = dict(context or {})
75
+ self.cause = cause
76
+
77
+ def __str__(self) -> str:
78
+ details: list[str] = []
79
+ if self.code is not None:
80
+ details.append(f"código RPC={self.code}")
81
+ if self.method is not None:
82
+ details.append(f"método={self.method}")
83
+
84
+ context_text = _format_context(self.context)
85
+ if context_text:
86
+ details.append(f"contexto={context_text}")
87
+
88
+ if self.cause is not None:
89
+ details.append(f"causa={self.cause}")
90
+
91
+ if not details:
92
+ return self.message
93
+
94
+ return f"{self.message} [{' | '.join(details)}]"
95
+
96
+ @classmethod
97
+ def from_rpc_error(
98
+ cls,
99
+ method: str,
100
+ rpc_error: Mapping[str, Any],
101
+ *,
102
+ context: Optional[Mapping[str, Any]] = None,
103
+ ) -> "RPCError":
104
+ code = rpc_error.get("code")
105
+ raw_message = str(rpc_error.get("message") or "")
106
+ friendly_message = _friendly_rpc_message(method, code if isinstance(code, int) else None, raw_message)
107
+
108
+ extra_details = rpc_error.get("data")
109
+ merged_context: dict[str, Any] = dict(context or {})
110
+ if extra_details is not None:
111
+ merged_context["rpc_data"] = extra_details
112
+
113
+ return cls(
114
+ friendly_message,
115
+ code=code if isinstance(code, int) else None,
116
+ method=method,
117
+ context=merged_context,
118
+ )
119
+
120
+ @classmethod
121
+ def from_transport_error(
122
+ cls,
123
+ method: str,
124
+ error: BaseException,
125
+ *,
126
+ context: Optional[Mapping[str, Any]] = None,
127
+ message: Optional[str] = None,
128
+ ) -> "RPCError":
129
+ friendly_message = message or f"Falha de comunicação ao executar {method}. Verifique o endpoint e tente novamente."
130
+ return cls(friendly_message, method=method, context=context, cause=error)
@@ -0,0 +1,164 @@
1
+ import unittest
2
+ from unittest.mock import patch, MagicMock
3
+ import json
4
+ import urllib.error
5
+ from .client import SolanaRPCClient
6
+ from .execptions import RPCError
7
+ from .entities import RPCConfig
8
+
9
+
10
+ class TestSolanaRPCClient(unittest.TestCase):
11
+ def setUp(self):
12
+ # max_retries=1 para testes rápidos (1 chamada inicial + 1 retry = 2 total)
13
+ self.config = RPCConfig(cluster="devnet", max_retries=1, timeout=1.0)
14
+
15
+ def _mock_urlopen_response(self, data: dict):
16
+ """Helper para criar um mock compatível com context manager"""
17
+ mock_resp = MagicMock()
18
+ mock_resp.read.return_value = json.dumps(data).encode()
19
+ mock_resp.__enter__ = MagicMock(return_value=mock_resp)
20
+ mock_resp.__exit__ = MagicMock(return_value=False)
21
+ return mock_resp
22
+
23
+ # 1. Resolução de endpoints
24
+ def test_endpoint_resolution(self):
25
+ c1 = SolanaRPCClient(RPCConfig(cluster="mainnet"))
26
+ self.assertEqual(c1.endpoint, "https://api.mainnet-beta.solana.com")
27
+
28
+ c2 = SolanaRPCClient(RPCConfig(custom_endpoint="https://meu-rpc.com"))
29
+ self.assertEqual(c2.endpoint, "https://meu-rpc.com")
30
+
31
+ # 2. Sucesso básico
32
+ @patch("urllib.request.urlopen")
33
+ def test_get_health_success(self, mock_urlopen):
34
+ mock_urlopen.return_value = self._mock_urlopen_response({"result": "ok"})
35
+ client = SolanaRPCClient(self.config)
36
+ self.assertEqual(client.get_health(), "ok")
37
+
38
+ @patch("urllib.request.urlopen")
39
+ def test_get_latest_blockhash(self, mock_urlopen):
40
+ mock_urlopen.return_value = self._mock_urlopen_response(
41
+ {"result": {"value": {"blockhash": "8xYz...abc"}}}
42
+ )
43
+ client = SolanaRPCClient(self.config)
44
+ self.assertEqual(client.get_latest_blockhash(), "8xYz...abc")
45
+
46
+ # 3. Retry em falhas de rede
47
+ @patch("urllib.request.urlopen")
48
+ def test_retry_on_network_error(self, mock_urlopen):
49
+ mock_urlopen.side_effect = urllib.error.URLError("connection refused")
50
+ client = SolanaRPCClient(self.config)
51
+ with self.assertRaises(RPCError):
52
+ client.get_health()
53
+ # 1 tentativa inicial + 1 retry = 2 chamadas
54
+ self.assertEqual(mock_urlopen.call_count, 2)
55
+
56
+ # 4. Tratamento de erro RPC
57
+ @patch("urllib.request.urlopen")
58
+ def test_rpc_error_response(self, mock_urlopen):
59
+ mock_urlopen.return_value = self._mock_urlopen_response(
60
+ {"error": {"code": -32600, "message": "Invalid Request"}}
61
+ )
62
+ client = SolanaRPCClient(self.config)
63
+ with self.assertRaises(RPCError) as ctx:
64
+ client.get_health()
65
+ self.assertIn("Requisição RPC inválida", str(ctx.exception))
66
+
67
+ # 5. Validação do payload de sendTransaction
68
+ @patch("urllib.request.urlopen")
69
+ def test_send_transaction_payload(self, mock_urlopen):
70
+ mock_urlopen.return_value = self._mock_urlopen_response({"result": "5sig..."})
71
+ client = SolanaRPCClient(self.config)
72
+ sig = client.send_transaction("base64payload==")
73
+ self.assertEqual(sig, "5sig...")
74
+
75
+ # Verifica se o JSON-RPC foi montado corretamente
76
+ call_args = mock_urlopen.call_args[0][0]
77
+ payload = json.loads(call_args.data)
78
+ self.assertEqual(payload["method"], "sendTransaction")
79
+ self.assertEqual(payload["params"][0], "base64payload==")
80
+ self.assertEqual(payload["params"][1]["encoding"], "base64")
81
+ self.assertEqual(payload["params"][1]["maxRetries"], 5)
82
+
83
+ class TestRPCRetryAndTimeout(unittest.TestCase):
84
+ def setUp(self):
85
+ self.config = RPCConfig(
86
+ cluster="devnet", max_retries=2, base_delay=0.01, max_delay=0.1, timeout=1.0
87
+ )
88
+
89
+ def _mock_resp(self, data: dict) -> MagicMock:
90
+ mock: MagicMock = MagicMock()
91
+ mock.read.return_value = json.dumps(data).encode()
92
+ mock.__enter__ = MagicMock(return_value=mock)
93
+ mock.__exit__ = MagicMock(return_value=False)
94
+ return mock
95
+
96
+ @patch("urllib.request.urlopen")
97
+ @patch("time.sleep")
98
+ def test_retry_on_http_429(self, mock_sleep, mock_urlopen):
99
+ # Falha 2x, sucesso na 3ª
100
+ mock_urlopen.side_effect = [
101
+ urllib.error.HTTPError("url", 429, "Too Many", {}, MagicMock()),
102
+ urllib.error.HTTPError("url", 429, "Too Many", {}, MagicMock()),
103
+ self._mock_resp({"result": "ok"}),
104
+ ]
105
+ client = SolanaRPCClient(self.config)
106
+ self.assertEqual(client.get_health(), "ok")
107
+ self.assertEqual(mock_urlopen.call_count, 3)
108
+ self.assertEqual(mock_sleep.call_count, 2)
109
+
110
+ @patch("urllib.request.urlopen")
111
+ @patch("time.sleep")
112
+ def test_no_retry_on_fatal_rpc_error(self, mock_sleep, mock_urlopen):
113
+ mock_urlopen.return_value = self._mock_resp(
114
+ {"error": {"code": -32600, "message": "Invalid Request"}}
115
+ )
116
+ client = SolanaRPCClient(self.config)
117
+ with self.assertRaises(RPCError) as ctx:
118
+ client.get_health()
119
+ self.assertIn("Requisição RPC inválida", str(ctx.exception))
120
+ mock_sleep.assert_not_called()
121
+
122
+ @patch("urllib.request.urlopen")
123
+ @patch("time.sleep")
124
+ def test_retry_exhausted_on_network_error(self, mock_sleep, mock_urlopen):
125
+ mock_urlopen.side_effect = ConnectionError("refused")
126
+ client = SolanaRPCClient(self.config)
127
+ with self.assertRaises(RPCError):
128
+ client.get_health()
129
+ self.assertEqual(mock_urlopen.call_count, 3)
130
+ self.assertEqual(mock_sleep.call_count, 2)
131
+
132
+ def test_timeout_applied_to_config(self):
133
+ client = SolanaRPCClient(RPCConfig(timeout=5.0))
134
+ self.assertEqual(client.cfg.timeout, 5.0)
135
+
136
+ @patch("urllib.request.urlopen")
137
+ def test_invalid_account_error_is_friendly(self, mock_urlopen):
138
+ mock_urlopen.return_value = self._mock_resp(
139
+ {"error": {"code": -32602, "message": "Invalid params"}}
140
+ )
141
+ client = SolanaRPCClient(self.config)
142
+ with self.assertRaises(RPCError) as ctx:
143
+ client.get_balance(" bad-account ")
144
+
145
+ error_text = str(ctx.exception)
146
+ self.assertIn("Parâmetros inválidos", error_text)
147
+ self.assertIn("address=bad-account", error_text)
148
+
149
+ @patch("urllib.request.urlopen")
150
+ def test_insufficient_funds_error_is_friendly(self, mock_urlopen):
151
+ mock_urlopen.return_value = self._mock_resp(
152
+ {"error": {"code": -32000, "message": "insufficient funds for rent"}}
153
+ )
154
+ client = SolanaRPCClient(self.config)
155
+
156
+ with self.assertRaises(RPCError) as ctx:
157
+ client.send_transaction("base64payload==")
158
+
159
+ error_text = str(ctx.exception)
160
+ self.assertIn("Saldo insuficiente", error_text)
161
+ self.assertIn("método=sendTransaction", error_text)
162
+
163
+ if __name__ == "__main__":
164
+ unittest.main()