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 +0 -0
- solinpy/client/__init__.py +0 -0
- solinpy/client/client.py +190 -0
- solinpy/client/entities.py +37 -0
- solinpy/client/execptions.py +130 -0
- solinpy/client/test_client.py +164 -0
- solinpy/tests/test_account_decoder.py +304 -0
- solinpy/tests/test_airdrop.py +188 -0
- solinpy/tests/test_devnet_integration.py +248 -0
- solinpy/tests/test_wallet.py +35 -0
- solinpy/transaction/__init__.py +0 -0
- solinpy/transaction/token.py +87 -0
- solinpy/utils/__init__.py +4 -0
- solinpy/utils/account_decoder.py +396 -0
- solinpy/utils/airdrop.py +107 -0
- solinpy/wallet/__init__.py +0 -0
- solinpy/wallet/manager.py +46 -0
- solinpy/wallet/mnemonic.py +53 -0
- solinpy-0.1.3.dist-info/METADATA +9 -0
- solinpy-0.1.3.dist-info/RECORD +22 -0
- solinpy-0.1.3.dist-info/WHEEL +5 -0
- solinpy-0.1.3.dist-info/top_level.txt +1 -0
solinpy/__init__.py
ADDED
|
File without changes
|
|
File without changes
|
solinpy/client/client.py
ADDED
|
@@ -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()
|