Skip to content
Open
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
8 changes: 4 additions & 4 deletions src/helius/rpc/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ class SignatureStatus(BaseModel):

slot: int
confirmations: int | None
err: dict | None
err: str | dict | None
confirmation_status: str | None


Expand All @@ -145,7 +145,7 @@ class TransactionSignature(BaseModel):

signature: str
slot: int
err: dict | None
err: str | dict | None
memo: str | None
block_time: int | None
confirmation_status: str | None
Expand Down Expand Up @@ -204,7 +204,7 @@ class VotingAccount(BaseModel):
class TransactionMetadata(BaseModel):
model_config = ConfigDict(alias_generator=AliasGenerator(validation_alias=to_camel))

err: dict | None
err: str | dict | None
fee: int
pre_balances: list[int]
post_balances: list[int]
Expand Down Expand Up @@ -303,7 +303,7 @@ class TransactionSignaturesDetail(BaseModel):
signature: str
slot: int
transaction_index: int
err: dict | None
err: str | dict | None
memo: str | None
block_time: int | None
confirmation_status: Literal["finalized", "confirmed"] | None
Expand Down
52 changes: 52 additions & 0 deletions tests/fixtures/transaction_errors.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
{
"getSignatureStatuses": {
"jsonrpc": "2.0", "id": 1,
"result": {
"context": {"apiVersion": "3.1.8", "slot": 82},
"value": [
{"slot": 48, "confirmations": null, "err": null, "status": {"Ok": null}, "confirmationStatus": "finalized"},
null
]
}
},
"getSignaturesForAddress": {
"jsonrpc": "2.0", "id": 1,
"result": [{
"signature": "5h6xBEauJ3PK6SWCZ1PGjBvj8vDdWG3KpwATGy1ARAXFSDwt8GFXM7W5Ncn16wmqokgpiKRLuS83KUxyZyv2sUYv",
"slot": 1054, "err": null, "memo": null,
"blockTime": 1641038400, "confirmationStatus": "finalized"
}]
},
"getTransaction": {
"jsonrpc": "2.0", "id": 1,
"result": {
"slot": 1054, "blockTime": 1641038400,
"meta": {
"err": null, "fee": 5000,
"preBalances": [1000000, 1], "postBalances": [995000, 1],
"preTokenBalances": [], "postTokenBalances": [],
"innerInstructions": [], "logMessages": [], "rewards": [], "status": {"Ok": null}
},
"transaction": {
"signatures": ["5h6xBEauJ3PK6SWCZ1PGjBvj8vDdWG3KpwATGy1ARAXFSDwt8GFXM7W5Ncn16wmqokgpiKRLuS83KUxyZyv2sUYv"],
"message": {
"accountKeys": ["Vote111111111111111111111111111111111111111", "11111111111111111111111111111111"],
"header": {"numRequiredSignatures": 1, "numReadonlySignedAccounts": 0, "numReadonlyUnsignedAccounts": 1},
"instructions": [], "recentBlockhash": "11111111111111111111111111111111"
}
},
"version": "legacy"
}
},
"getTransactionsForAddress": {
"jsonrpc": "2.0", "id": "1",
"result": {
"data": [{
"signature": "5h6xBEauJ3PK6SWCZ1PGjBvj8vDdWG3KpwATGy1ARAXFSDwt8GFXM7W5Ncn16wmqokgpiKRLuS83KUxyZyv2sUYv",
"slot": 1054, "transactionIndex": 42, "err": null, "memo": null,
"blockTime": 1641038400, "confirmationStatus": "finalized"
}],
"paginationToken": "1055:5"
}
}
}
140 changes: 140 additions & 0 deletions tests/unit/solana_rpc/test_transaction_errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
"""Offline transaction-error regression tests using documented response shapes.

The fixture values are synthetic; no real transaction or signature is verified.
Response schemas:
https://solana.com/docs/rpc/http/getsignaturestatuses
https://solana.com/docs/rpc/http/getsignaturesforaddress
https://solana.com/docs/rpc/http/gettransaction
https://www.helius.dev/docs/api-reference/rpc/http/gettransactionsforaddress
Error values: https://solana.com/docs/rpc/json-structures#transaction-errors
"""

import json
from pathlib import Path

import httpx
import pytest
import respx
from pydantic import ValidationError

from helius.rpc import SolanaRpcClient
from helius.rpc.models import (
SignatureStatus,
TransactionMetadata,
TransactionSignature,
TransactionSignaturesDetail,
)

SIGNATURE = "5h6xBEauJ3PK6SWCZ1PGjBvj8vDdWG3KpwATGy1ARAXFSDwt8GFXM7W5Ncn16wmqokgpiKRLuS83KUxyZyv2sUYv"
MISSING_SIGNATURE = "1" * 64
ADDRESS = "Vote111111111111111111111111111111111111111"
CASES = [
pytest.param(
"getSignatureStatuses", SignatureStatus, ("result", "value", 0), id="status"
),
pytest.param(
"getSignaturesForAddress", TransactionSignature, ("result", 0), id="signature"
),
pytest.param(
"getTransaction", TransactionMetadata, ("result", "meta"), id="metadata"
),
pytest.param(
"getTransactionsForAddress",
TransactionSignaturesDetail,
("result", "data", 0),
id="address-signatures",
),
]
ERRORS = [
pytest.param(None, id="success"),
pytest.param("BlockhashNotFound", id="simple-error"),
pytest.param({"InstructionError": [0, {"Custom": 1}]}, id="instruction-error"),
]


@pytest.fixture
def responses():
path = Path(__file__).resolve().parents[2] / "fixtures" / "transaction_errors.json"
return json.loads(path.read_text(encoding="utf-8"))


def error_response(responses, method, path, error):
response = responses[method]
payload = response
for key in path:
payload = payload[key]
payload["err"] = error
# Keep the deprecated status field consistent with the chosen error.
if "status" in payload:
payload["status"] = {"Ok": None} if error is None else {"Err": error}
return response, payload


@pytest.mark.parametrize("method, model, path", CASES)
@pytest.mark.parametrize("error", ERRORS)
def test_models_preserve_documented_transaction_errors(
responses, method, model, path, error
):
_, payload = error_response(responses, method, path, error)
parsed = model.model_validate(payload)
assert parsed.err == error


@pytest.mark.parametrize("method, model, path", CASES)
@pytest.mark.parametrize("error", ERRORS)
@respx.mock
def test_rpc_methods_preserve_transaction_errors(responses, method, model, path, error):
response, _ = error_response(responses, method, path, error)
route = respx.post("https://rpc.example.invalid/").mock(
return_value=httpx.Response(200, json=response)
)
with SolanaRpcClient(
base_url="https://rpc.example.invalid", api_key="test"
) as client:
if method == "getSignatureStatuses":
context, values = client.get_signature_statuses(
signatures=[SIGNATURE, MISSING_SIGNATURE]
)
assert context == {"apiVersion": "3.1.8", "slot": 82}
assert values[1] is None
actual = values[0]
params = [[SIGNATURE, MISSING_SIGNATURE]]
elif method == "getSignaturesForAddress":
actual = client.get_signatures_for_address(address=ADDRESS, limit=1)[0]
params = [ADDRESS, {"limit": 1}]
elif method == "getTransaction":
actual = client.get_transaction(transaction_signature=SIGNATURE).meta
params = [SIGNATURE]
else:
values, pagination = client.get_transactions_for_address(
address=ADDRESS, transaction_details="signatures", limit=1
)
assert pagination == "1055:5"
actual = values[0]
params = [ADDRESS, {"transactionDetails": "signatures", "limit": 1}]
assert isinstance(actual, model)
assert actual.err == error
request = route.calls.last.request
assert request.url.params["api-key"] == "test"
body = json.loads(request.content)
assert body["method"] == method
assert body["params"] == params


@pytest.mark.parametrize("method, model, path", CASES)
@pytest.mark.parametrize("error", [False, 7, []], ids=["boolean", "number", "array"])
def test_models_reject_invalid_error_types(responses, method, model, path, error):
_, payload = error_response(responses, method, path, error)
with pytest.raises(ValidationError) as exc:
model.model_validate(payload)
assert all(error["loc"][0] == "err" for error in exc.value.errors())


@pytest.mark.parametrize("method, model, path", CASES)
def test_error_field_remains_required(responses, method, model, path):
_, payload = error_response(responses, method, path, None)
del payload["err"]
with pytest.raises(ValidationError) as exc:
model.model_validate(payload)
assert exc.value.errors()[0]["loc"] == ("err",)
assert exc.value.errors()[0]["type"] == "missing"