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
79 changes: 41 additions & 38 deletions blockapi/test/v2/api/data/solana/staked_solana_response.json
Original file line number Diff line number Diff line change
@@ -1,47 +1,50 @@
{
"jsonrpc": "2.0",
"result": [
{
"account": {
"data": {
"parsed": {
"info": {
"meta": {
"authorized": {
"staker": "6T4ddWpeAvpU49aePYV6YiWPkMeNbBuWwGtPTphZ3xn6",
"withdrawer": "6T4ddWpeAvpU49aePYV6YiWPkMeNbBuWwGtPTphZ3xn6"
"result": {
"accounts": [
{
"account": {
"data": {
"parsed": {
"info": {
"meta": {
"authorized": {
"staker": "6T4ddWpeAvpU49aePYV6YiWPkMeNbBuWwGtPTphZ3xn6",
"withdrawer": "6T4ddWpeAvpU49aePYV6YiWPkMeNbBuWwGtPTphZ3xn6"
},
"lockup": {
"custodian": "11111111111111111111111111111111",
"epoch": 0,
"unixTimestamp": 0
},
"rentExemptReserve": "2282880"
},
"lockup": {
"custodian": "11111111111111111111111111111111",
"epoch": 0,
"unixTimestamp": 0
},
"rentExemptReserve": "2282880"
},
"stake": {
"creditsObserved": 227041787,
"delegation": {
"activationEpoch": "221",
"deactivationEpoch": "18446744073709551615",
"stake": "179062913955311",
"voter": "9QU2QSxhb24FUX3Tu2FpczXjpK3VYrvRudywSZaM29mF",
"warmupCooldownRate": 0.25
"stake": {
"creditsObserved": 227041787,
"delegation": {
"activationEpoch": "221",
"deactivationEpoch": "18446744073709551615",
"stake": "179062913955311",
"voter": "9QU2QSxhb24FUX3Tu2FpczXjpK3VYrvRudywSZaM29mF",
"warmupCooldownRate": 0.25
}
}
}
},
"type": "delegated"
},
"type": "delegated"
"program": "stake",
"space": 200
},
"program": "stake",
"executable": false,
"lamports": 179486959040566,
"owner": "Stake11111111111111111111111111111111111111",
"rentEpoch": 18446744073709551615,
"space": 200
},
"executable": false,
"lamports": 179486959040566,
"owner": "Stake11111111111111111111111111111111111111",
"rentEpoch": 18446744073709551615,
"space": 200
},
"pubkey": "9hkkrNAtEpi7iDHNJgE4qse5jh3ue8r2dtyWRLSBT3fe"
}
],
"pubkey": "9hkkrNAtEpi7iDHNJgE4qse5jh3ue8r2dtyWRLSBT3fe"
}
],
"paginationKey": null
},
"id": 1
}
}
98 changes: 95 additions & 3 deletions blockapi/test/v2/api/test_solana.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from decimal import Decimal
from unittest.mock import patch
from unittest.mock import call, patch

import pytest
from requests_mock import ANY, Mocker
Expand Down Expand Up @@ -80,6 +80,7 @@ def test_use_base_url_in_post(
das_asset_batch_response,
staked_solana_response,
):
rpc_url = 'https://mainnet.helius-rpc.com/'
test_addr = '5PjMxaijeVVQtuEzxK2NxyJeWwUbpTsi2uXuZ653WoHu'
empty_token_accounts = '{"jsonrpc":"2.0","result":{"context":{"apiVersion":"1.17.34","slot":268207149},"value":[]},"id":1}'

Expand All @@ -94,13 +95,13 @@ def test_use_base_url_in_post(
)

def get_text(*args, **kwargs):
assert args[0].url == 'https://proxy/solana/'
assert args[0].url == rpc_url
data = next(iterator)
return data

with Mocker() as m:
m.post(ANY, text=get_text),
api = SolanaApi(base_url='https://proxy/solana/')
api = SolanaApi(base_url=rpc_url)
api.get_balance(test_addr)


Expand Down Expand Up @@ -237,6 +238,97 @@ def test_parse_staked_balance_skips_undelegated():
assert result.asset_type == AssetType.STAKED


def test_fetch_staked_sol_uses_v2_pagination():
api = SolanaApi(base_url='https://mainnet.helius-rpc.com/')
address = '5PjMxaijeVVQtuEzxK2NxyJeWwUbpTsi2uXuZ653WoHu'
first_account = {'pubkey': 'first'}
second_account = {'pubkey': 'second'}

with patch.object(
api,
'_request',
side_effect=[
{
'jsonrpc': '2.0',
'id': 1,
'result': {
'accounts': [first_account],
'paginationKey': 'next-page',
},
},
{
'jsonrpc': '2.0',
'id': 2,
'result': {
'accounts': [],
'paginationKey': 'last-page',
},
},
{
'jsonrpc': '2.0',
'id': 3,
'result': {
'accounts': [second_account],
'paginationKey': None,
},
},
],
) as request:
response = api._fetch_staked_sol(address)

config = {
'filters': [
{
'memcmp': {
'offset': api.STAKE_AUTHORITY_OFFSET,
'bytes': address,
'encoding': 'base58',
}
}
],
'encoding': 'jsonParsed',
'commitment': 'finalized',
'limit': api.api_options.max_items_per_page,
}
assert request.call_args_list == [
call(
method='getProgramAccountsV2',
params=[api.STAKE_PROGRAM_ID, config],
),
call(
method='getProgramAccountsV2',
params=[
api.STAKE_PROGRAM_ID,
{**config, 'paginationKey': 'next-page'},
],
),
call(
method='getProgramAccountsV2',
params=[
api.STAKE_PROGRAM_ID,
{**config, 'paginationKey': 'last-page'},
],
),
]
assert response == {
'jsonrpc': '2.0',
'id': 3,
'result': [first_account, second_account],
}


def test_fetch_staked_sol_uses_legacy_method_for_non_helius_rpc():
api = SolanaApi()

with patch.object(api, '_request', return_value={'result': []}) as request:
response = api._fetch_staked_sol('address')

request.assert_called_once()
assert request.call_args.kwargs['method'] == 'getProgramAccounts'
assert 'limit' not in request.call_args.kwargs['params'][1]
assert response == {'result': []}


def test_das_cache_stores_sentinel_for_unknown_mint():
api = SolanaApi()
unknown_mint = 'UnknownMint111111111111111111111111111111111'
Expand Down
58 changes: 41 additions & 17 deletions blockapi/v2/api/solana.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import json
import logging
from typing import Optional, Union
from urllib.parse import urlparse

from cytoolz import reduceby
from requests import Response
Expand Down Expand Up @@ -79,6 +80,7 @@ class SolanaApi(CustomizableBlockchainApi, BalanceMixin):
TOKEN_2022_PROGRAM_ID = 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb'
STAKE_PROGRAM_ID = 'Stake11111111111111111111111111111111111111'
STAKE_AUTHORITY_OFFSET = 44
HELIUS_RPC_DOMAIN = 'helius-rpc.com'
DAS_BATCH_SIZE = 1000
_JSONRPC_INVALID_PARAMS = -32602

Expand Down Expand Up @@ -314,25 +316,47 @@ def _build_coin_from_das_asset(self, asset: dict) -> Optional[Coin]:

def _fetch_staked_sol(self, address: str) -> dict:
"""Fetch staked SOL accounts for a given address."""
return self._request(
method='getProgramAccounts',
params=[
self.STAKE_PROGRAM_ID,
config = {
'filters': [
{
'filters': [
{
'memcmp': {
'offset': self.STAKE_AUTHORITY_OFFSET,
'bytes': address,
'encoding': 'base58',
}
}
],
'encoding': 'jsonParsed',
'commitment': 'finalized',
},
'memcmp': {
'offset': self.STAKE_AUTHORITY_OFFSET,
'bytes': address,
'encoding': 'base58',
}
}
],
'encoding': 'jsonParsed',
'commitment': 'finalized',
}

hostname = urlparse(self.base_url).hostname or ''
is_helius_rpc = hostname == self.HELIUS_RPC_DOMAIN or hostname.endswith(
f'.{self.HELIUS_RPC_DOMAIN}'
)
if not is_helius_rpc:
return self._request(
method='getProgramAccounts',
params=[self.STAKE_PROGRAM_ID, config],
)

config['limit'] = self.api_options.max_items_per_page

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

recheck this setting for helius

accounts = []

while True:
response = self._request(
method='getProgramAccountsV2',
params=[self.STAKE_PROGRAM_ID, config],
)
page = response['result']
accounts.extend(page['accounts'])

pagination_key = page.get('paginationKey')
if pagination_key is None:
response['result'] = accounts
return response

config = {**config, 'paginationKey': pagination_key}

# ── Balance parsing ────────────────────────────────────────

Expand Down Expand Up @@ -373,7 +397,7 @@ def _parse_staked_balance(self, response: dict) -> Optional[BalanceItem]:
def _parse_rent_reserve(
self, staked_sol: BalanceItem, raw_staked_sol: dict
) -> BalanceItem:
"""Parse rent reserve from getProgramAccounts response.
"""Parse rent reserve from a program accounts response.

Uses result[].account.lamports already returned by _fetch_staked_sol,
avoiding a separate getMultipleAccounts call.
Expand Down
Loading