Skip to content

Commit 18bcf1c

Browse files
Merge pull request #149 from olegsta/add_balance
Feature: Retrieve All Balances in a Single Call #10
2 parents 282388f + f1484d9 commit 18bcf1c

File tree

5 files changed

+190
-30
lines changed

5 files changed

+190
-30
lines changed

README.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,11 @@ curl --location --request GET
342342
Use the `crypto_list` array; the `crypto` array exists only for backward compatibility. In the `crypto_list` array:
343343
- `name` is used when forming the endpoint for invoice creation requests,
344344
- `display_name` is the cryptocurrency human-readable format.
345+
346+
**Notes & caveats**
347+
348+
SHKeeper uses a short-lived in-memory TTL cache to speed up the /crypto endpoints. Cached data about available cryptocurrencies and node status may be slightly outdated (up to 60 seconds). In multi-process deployments, each instance keeps its own cache, which may cause minor differences between responses. The cache reduces short-term node or network fluctuations but may briefly delay reflecting real-time status changes.
349+
345350
<a name="invoice-creation"></a>
346351
#### 5.2.3. Invoice Creation
347352

@@ -810,6 +815,68 @@ curl --location --request GET 'https://demo.shkeeper.io/api/v1/ETH/balance' \
810815
}
811816
```
812817

818+
<a name="get-all-balances"></a>
819+
##### 5.2.11.5. Get All Crypto Balances
820+
821+
**Endpoint:** `/api/v1/crypto/balances`.
822+
**Authorization:** ApiKey.
823+
**HTTP request method:** GET.
824+
**Query Parameters: (optional)**.
825+
includes — Comma-separated list of crypto identifiers to return balances for (e.g., BTC,ETH,TRX). Case-insensitive. If omitted or empty, returns balances for all enabled cryptos. Unknown/disabled cryptos are ignored.
826+
827+
**Curl Example:**
828+
```
829+
curl --location --request GET 'https://demo.shkeeper.io/api/v1/crypto/balances' \
830+
--header 'X-Shkeeper-API-Key: nApijGv8djih7ozY' \
831+
--header 'Content-Type: application/json'
832+
```
833+
Example Request (subset of cryptos):
834+
```
835+
curl --location --request GET 'https://demo.shkeeper.io/api/v1/crypto/balances?includes=BTC,ETH'
836+
--header 'X-Shkeeper-API-Key: nApijGv8djih7ozY' \
837+
--header 'Content-Type: application/json'
838+
```
839+
Successful Response:
840+
```
841+
[
842+
{
843+
"name": "BTC",
844+
"display_name": "Bitcoin",
845+
"amount_crypto": "0.12345678",
846+
"rate": "70616.07",
847+
"fiat": "USD",
848+
"amount_fiat": "8700.12",
849+
"server_status": "online"
850+
},
851+
{
852+
"name": "ETH",
853+
"display_name": "Ethereum",
854+
"amount_crypto": "1.2345",
855+
"rate": "3015.50",
856+
"fiat": "USD",
857+
"amount_fiat": "3721.05",
858+
"server_status": "online"
859+
}
860+
]
861+
```
862+
Failure Response (invalid includes / no valid cryptos requested):
863+
```
864+
{
865+
"status": "error",
866+
"message": "No valid cryptos requested"
867+
}
868+
```
869+
870+
**Notes & caveats**
871+
872+
The response is always a plain JSON array, no extra envelope or metadata.
873+
The order of items is deterministic:
874+
If includes is provided → preserves order from request (valid entries only)
875+
If includes is absent → sorted alphabetically by name.
876+
Partial failures (e.g., RPC error for a coin) result in that coin being omitted; no new error fields are introduced.
877+
878+
SHKeeper uses a short-lived in-memory TTL cache to speed up the /crypto/balances endpoints. Cached data about available cryptocurrencies and node status may be slightly outdated (up to 60 seconds). In multi-process deployments, each instance keeps its own cache, which may cause minor differences between responses. The cache reduces short-term node or network fluctuations but may briefly delay reflecting real-time status changes.
879+
813880
<a name="receiving-callback"></a>
814881
### 5.3 Receiving callback
815882

shkeeper/api_v1.py

Lines changed: 17 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@
3333
WalletEncryptionRuntimeStatus,
3434
)
3535
from shkeeper.exceptions import NotRelatedToAnyInvoice
36-
36+
from shkeeper.services.crypto_cache import get_available_cryptos
37+
from shkeeper.services.balance_service import get_balances
3738

3839
bp = Blueprint("api_v1", __name__, url_prefix="/api/v1/")
3940

@@ -46,39 +47,25 @@
4647

4748
@bp.route("/crypto")
4849
def list_crypto():
49-
filtered_list = []
50-
crypto_list = []
51-
disable_on_lags = app.config.get("DISABLE_CRYPTO_WHEN_LAGS")
52-
cryptos = Crypto.instances.values()
53-
filtered_cryptos = []
54-
55-
for crypto in cryptos:
56-
if crypto.wallet.enabled:
57-
filtered_cryptos.append(crypto)
58-
59-
def get_crypto_status(crypto):
60-
return crypto, crypto.getstatus()
61-
62-
with ThreadPoolExecutor() as executor:
63-
results = list(executor.map(get_crypto_status, filtered_cryptos))
64-
65-
for crypto, status in results:
66-
if status == "Offline":
67-
continue
68-
if disable_on_lags and status != "Synced":
69-
continue
70-
filtered_list.append(crypto.crypto)
71-
crypto_list.append({
72-
"name": crypto.crypto,
73-
"display_name": crypto.display_name
74-
})
75-
50+
data = get_available_cryptos()
7651
return {
7752
"status": "success",
78-
"crypto": sorted(filtered_list),
79-
"crypto_list": sorted(crypto_list, key=itemgetter("name")),
53+
"crypto": data["filtered"],
54+
"crypto_list": data["crypto_list"],
8055
}
8156

57+
@bp.get("/crypto/balances")
58+
@api_key_required
59+
def get_all_balances():
60+
includes = request.args.get("includes")
61+
if includes:
62+
includes = includes.split(",")
63+
else:
64+
includes = None
65+
balances, error = get_balances(includes)
66+
if error:
67+
return {"status": "error", "message": error}, 400
68+
return balances
8269

8370
@bp.get("/<crypto_name>/generate-address")
8471
@login_required
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
from concurrent.futures import ThreadPoolExecutor
2+
from decimal import Decimal
3+
from shkeeper.services.crypto_cache import get_available_cryptos
4+
from shkeeper.modules.classes.crypto import Crypto
5+
from shkeeper.utils import format_decimal
6+
from shkeeper.models import ExchangeRate
7+
from flask import current_app
8+
9+
def _build_balance(crypto_name: str, logger, app):
10+
with app.app_context():
11+
crypto = Crypto.instances.get(crypto_name)
12+
if not crypto:
13+
return None
14+
fiat = "USD"
15+
try:
16+
rate = ExchangeRate.get(fiat, crypto_name).get_rate()
17+
crypto_amount = Decimal(crypto.balance() or 0)
18+
amount_fiat = crypto_amount * Decimal(rate)
19+
server_status = crypto.getstatus()
20+
except Exception as e:
21+
logger.exception(f"_build_balance exception for {crypto_name}")
22+
return None
23+
return {
24+
"name": crypto.crypto,
25+
"display_name": crypto.display_name,
26+
"amount_crypto": format_decimal(crypto_amount),
27+
"rate": format_decimal(rate),
28+
"fiat": fiat,
29+
"amount_fiat": format_decimal(amount_fiat),
30+
"server_status": server_status,
31+
}
32+
33+
def get_balances(includes: list[str] | None):
34+
app = current_app._get_current_object()
35+
logger = app.logger
36+
data = get_available_cryptos()
37+
available_coins = data["filtered"]
38+
if includes:
39+
includes = [c.strip().upper() for c in includes if c.strip()]
40+
target = [c for c in includes if c in available_coins]
41+
if not target:
42+
return None, "No valid cryptos requested"
43+
else:
44+
target = sorted(available_coins)
45+
with ThreadPoolExecutor() as executor:
46+
results = list(
47+
executor.map(lambda c: _build_balance(c, logger, app), target)
48+
)
49+
balances = [x for x in results if x]
50+
return balances, None

shkeeper/services/cache_service.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import time
2+
from typing import Callable, Any
3+
4+
class TTLCache:
5+
def __init__(self):
6+
self._cache = {}
7+
8+
def remember(self, key: str, ttl: int, callback: Callable[[], Any]):
9+
entry = self._cache.get(key)
10+
if entry and entry["expires"] > time.time():
11+
return entry["value"]
12+
value = callback()
13+
self._cache[key] = {
14+
"value": value,
15+
"expires": time.time() + ttl
16+
}
17+
return value
18+
cache = TTLCache()

shkeeper/services/crypto_cache.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
from concurrent.futures import ThreadPoolExecutor
2+
from operator import itemgetter
3+
from flask import current_app as app
4+
from shkeeper.modules.classes.crypto import Crypto
5+
from shkeeper.services.cache_service import cache
6+
7+
CACHE_TTL = 60 # seconds
8+
9+
def _fetch_available_cryptos():
10+
disable_on_lags = app.config.get("DISABLE_CRYPTO_WHEN_LAGS")
11+
cryptos = [c for c in Crypto.instances.values() if c.wallet.enabled]
12+
def check_status(crypto):
13+
return crypto, crypto.getstatus()
14+
15+
with ThreadPoolExecutor() as executor:
16+
results = list(executor.map(check_status, cryptos))
17+
18+
filtered = []
19+
crypto_list = []
20+
21+
for crypto, status in results:
22+
if status == "Offline":
23+
continue
24+
if disable_on_lags and status != "Synced":
25+
continue
26+
27+
filtered.append(crypto.crypto)
28+
crypto_list.append({
29+
"name": crypto.crypto,
30+
"display_name": crypto.display_name
31+
})
32+
return {
33+
"filtered": sorted(filtered),
34+
"crypto_list": sorted(crypto_list, key=itemgetter("name")),
35+
}
36+
37+
def get_available_cryptos():
38+
return cache.remember("available_cryptos", CACHE_TTL, _fetch_available_cryptos)

0 commit comments

Comments
 (0)