Skip to content

Commit 0fb434f

Browse files
committed
Add multi-currency system with exchange rate conversion
- Store earnings in native currency (MYST, GRASS) instead of USD - Add exchange_rates.py: CoinGecko crypto rates + Frankfurter fiat rates, refreshed every 15min - Frontend: locale-detected display currency with Intl.NumberFormat, persisted in localStorage - Fix MystNodes collector: report MYST tokens, not USD - Fix Grass collector: report GRASS tokens, not GRASS_POINTS - Settings: currency dropdown with popular currencies + all ISO 4217 - Dashboard totals convert all currencies to USD server-side - Update AGENTS.md with currency system docs and worker deployment topology
1 parent f078e84 commit 0fb434f

8 files changed

Lines changed: 299 additions & 30 deletions

File tree

AGENTS.md

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -350,7 +350,7 @@ Working collectors (12/12 deployed services):
350350
- **PacketStream** -- Manual JWT cookie, HTML scraping `window.userData`
351351
- **ProxyRack** -- API key auth, POST `/api/balance`. Per-device bandwidth (not earnings) via POST `/api/bandwidth` with `device_id` param.
352352
- **Storj** -- API URL-based
353-
- **Grass** -- Bearer token from localStorage (`app.grass.io`), `api.getgrass.io`. Returns points, not USD.
353+
- **Grass** -- Bearer token from localStorage (`app.grass.io`), `api.getgrass.io`. Returns GRASS token balance (converted to USD via CoinGecko).
354354
- **Bytelixir** -- Laravel session cookie (expires ~3.5h), `dash.bytelixir.com`. hCaptcha blocks automated login.
355355

356356
#### Per-Node/Per-Device Earnings Support
@@ -373,8 +373,8 @@ Only MystNodes has a real per-node earnings API. Research on all 12 services:
373373
| **ProxyRack** | Dashboard behind Cloudflare. Need API key from browser. Device UUIDs must be manually registered in `peer.proxyrack.com` dashboard. |
374374
| **SpeedShare** | API domain (`api.speedshare.app`) misconfigured -- returns Telegraf metrics exporter output. Service non-functional. |
375375
| **Nodepay** | Behind Cloudflare protection. API access requires browser session cookies. |
376-
| **Grass** | Token must be extracted from browser localStorage at `app.grass.io`. Returns points (GRASS_POINTS), not USD. |
377-
| **Bytelixir** | Laravel session cookie expires ~3.5h. hCaptcha blocks automated login. Must manually extract cookie from browser. Most session-fragile service. |
376+
| **Grass** | Token must be extracted from browser localStorage at `app.grass.io`. Returns GRASS tokens (converted via exchange rates). |
377+
| **Bytelixir** | Laravel session cookie. hCaptcha blocks automated login. Must manually extract cookie from browser. With "Remember Me" ticked, sessions last days/weeks. |
378378

379379
---
380380

@@ -440,9 +440,46 @@ Fleet API key set via `CASHPILOT_API_KEY` env var on all instances.
440440
- **MMN API key is critical**: The Mysterium container must have `MYSTNODES_API_KEY` env var or `[mmn] api-key` in `config-mainnet.toml` to link the node to the user's MystNodes cloud account.
441441
- **Node identity is per-volume**: The Mysterium keystore lives in the Docker volume (`mysterium-data:/var/lib/mysterium-node/keystore/`). Deleting the volume or creating a new container without the same volume generates a NEW blockchain identity.
442442
- **Registration is blockchain-based**: New identities must be registered on Polygon. This is triggered by Hermes and requires the MMN API key. If Hermes returns "internal error", it's a temporary server-side issue.
443-
- **Per-node earnings**: The MystNodes cloud API (`GET /api/v2/node?page=1&itemsPerPage=100`) returns per-node 30-day earnings in MYST. The `earningsTotal` endpoint returns pre-converted USD total.
443+
- **Per-node earnings**: The MystNodes cloud API (`GET /api/v2/node?page=1&itemsPerPage=100`) returns per-node 30-day earnings in MYST. The `earningsTotal` endpoint returns MYST token balance (not USD).
444444
- **Image name**: `mysteriumnetwork/myst` (NOT `mysteriumnet/myst`).
445445

446+
### Multi-Currency & Exchange Rates
447+
448+
CashPilot stores earnings in each service's **native currency** (USD, MYST, GRASS, STORJ, etc.) and converts for display.
449+
450+
- **`app/exchange_rates.py`**: Fetches crypto→USD rates from CoinGecko (free, no key) and USD→fiat from Frankfurter API. Cached in memory, refreshed every 15 min + on startup.
451+
- **CoinGecko IDs**: `mysterium` (MYST), `grass` (GRASS). Add new tokens to `CRYPTO_IDS` dict.
452+
- **Frontend conversion**: `app.js` fetches `/api/exchange-rates`, stores rates client-side. `formatCurrency(val, nativeCurrency)` converts native→USD→display currency. `formatNative(val, currency)` shows original token amount alongside converted value.
453+
- **Display currency**: Auto-detected from `navigator.language` locale (e.g. `es` → EUR). User can override in Settings. Persisted in `localStorage('cp-display-currency')`.
454+
- **Dashboard totals**: Backend converts all non-USD balances to USD via `exchange_rates.to_usd()` for the summary total.
455+
- **Collector currencies**: Each collector returns its native currency in `EarningsResult.currency`. MystNodes returns `MYST`, Grass returns `GRASS`, all others return `USD`.
456+
457+
### Worker Deployment
458+
459+
**Always use the dedicated worker image** (`drumsergio/cashpilot-worker`), never the UI image for workers. The worker image runs `app.worker_api:app` on port 8081.
460+
461+
**Topology:**
462+
- **watchtower**: `drumsergio/cashpilot` (UI, standalone mode — includes embedded local worker). Port 8085→8080. Manages local containers + collects all earnings.
463+
- **geiserback** (192.168.10.110): `drumsergio/cashpilot-worker`. Port 8081. Same subnet as watchtower — uses direct IP for `CASHPILOT_UI_URL`.
464+
- **geiserct** (192.168.20.5, user `geiser`): `drumsergio/cashpilot-worker`. Port 8081. Different subnet — **must use Tailscale MagicDNS** (`watchtower.mango-alpha.ts.net`) for `CASHPILOT_UI_URL`.
465+
466+
```bash
467+
# Worker deployment (geiserback or geiserct)
468+
docker run -d --name cashpilot-worker --restart unless-stopped \
469+
-p 8081:8081 \
470+
-v /var/run/docker.sock:/var/run/docker.sock \
471+
-e TZ=Europe/Madrid \
472+
-e "CASHPILOT_UI_URL=http://192.168.10.100:8085" \
473+
-e "CASHPILOT_API_KEY=XV5LwdpApfERQmhKbbcANI8nz3pca1Up" \
474+
-e CASHPILOT_WORKER_NAME=<server-name> \
475+
--security-opt no-new-privileges:true \
476+
drumsergio/cashpilot-worker:latest
477+
```
478+
479+
**Cross-subnet note**: geiserct uses `CASHPILOT_UI_URL=http://watchtower.mango-alpha.ts.net:8085` since 192.168.20.x cannot reach 192.168.10.x directly.
480+
481+
**Important**: The `entrypoint.sh` does NOT switch modes — it only sets up Docker socket permissions. Mode is determined by which Docker image runs (`Dockerfile` → `app.main:app`, `Dockerfile.worker` → `app.worker_api:app`).
482+
446483
---
447484

448485
## Development

app/collectors/bytelixir.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
To get the cookie: open dash.bytelixir.com, log in (tick "Remember Me"),
88
press F12 > Application > Cookies, and copy the `bytelixir_session` value.
99
10-
Note: session expires after ~3.5 hours. When expired, the collector
10+
Note: session lifetime depends on "Remember Me" — with it ticked, cookies
11+
last days/weeks. Without it, they expire quickly. When expired, the collector
1112
returns an error that surfaces as a notification in the CashPilot UI.
1213
"""
1314

app/collectors/grass.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ async def collect(self) -> EarningsResult:
6161
return EarningsResult(
6262
platform=self.platform,
6363
balance=round(total_points, 4),
64-
currency="GRASS_POINTS",
64+
currency="GRASS",
6565
)
6666
except Exception as exc:
6767
logger.error("Grass collection failed: %s", exc)

app/collectors/mystnodes.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -97,13 +97,13 @@ async def collect(self) -> EarningsResult:
9797
resp.raise_for_status()
9898
data = resp.json()
9999

100-
# earningsTotal is already in USD
101-
total_usd = float(data.get("earningsTotal", 0))
100+
# earningsTotal is in MYST tokens (Polygon)
101+
total_myst = float(data.get("earningsTotal", 0))
102102

103103
return EarningsResult(
104104
platform=self.platform,
105-
balance=round(total_usd, 4),
106-
currency="USD",
105+
balance=round(total_myst, 4),
106+
currency="MYST",
107107
)
108108
except Exception as exc:
109109
logger.error("MystNodes collection failed: %s", exc)

app/exchange_rates.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"""Exchange rate service for CashPilot.
2+
3+
Fetches crypto-to-USD rates from CoinGecko and USD-to-fiat rates from
4+
Frankfurter API. Rates are cached in memory with periodic refresh
5+
(every 15 minutes via the scheduler).
6+
7+
No API keys required — both services are free-tier.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import logging
13+
import time
14+
from typing import Any
15+
16+
import httpx
17+
18+
logger = logging.getLogger(__name__)
19+
20+
# CoinGecko IDs for crypto tokens tracked by CashPilot collectors.
21+
# Map: our internal currency code -> CoinGecko coin id
22+
CRYPTO_IDS: dict[str, str] = {
23+
"MYST": "mysterium",
24+
"GRASS": "grass",
25+
}
26+
27+
CACHE_TTL = 900 # 15 minutes
28+
29+
# In-memory caches
30+
_fiat_rates: dict[str, float] = {"USD": 1.0}
31+
_crypto_usd: dict[str, float] = {}
32+
_last_fetch: float = 0
33+
34+
35+
async def refresh() -> None:
36+
"""Fetch latest exchange rates from external APIs."""
37+
global _fiat_rates, _crypto_usd, _last_fetch
38+
39+
try:
40+
async with httpx.AsyncClient(timeout=15) as client:
41+
# --- Crypto rates from CoinGecko (free, no key) ---
42+
if CRYPTO_IDS:
43+
ids = ",".join(CRYPTO_IDS.values())
44+
resp = await client.get(
45+
"https://api.coingecko.com/api/v3/simple/price",
46+
params={"ids": ids, "vs_currencies": "usd"},
47+
)
48+
if resp.status_code == 200:
49+
data = resp.json()
50+
for token, cg_id in CRYPTO_IDS.items():
51+
price = (data.get(cg_id) or {}).get("usd")
52+
if price is not None:
53+
_crypto_usd[token] = float(price)
54+
55+
# --- Fiat rates from Frankfurter (free, no key) ---
56+
resp = await client.get(
57+
"https://api.frankfurter.app/latest",
58+
params={"from": "USD"},
59+
)
60+
if resp.status_code == 200:
61+
data = resp.json()
62+
new_rates: dict[str, float] = {"USD": 1.0}
63+
for code, rate in data.get("rates", {}).items():
64+
new_rates[code] = float(rate)
65+
_fiat_rates = new_rates
66+
67+
_last_fetch = time.time()
68+
logger.info(
69+
"Exchange rates updated: %d fiat currencies, %d crypto tokens",
70+
len(_fiat_rates),
71+
len(_crypto_usd),
72+
)
73+
except Exception as exc:
74+
logger.error("Exchange rate fetch failed: %s", exc)
75+
76+
77+
def get_all() -> dict[str, Any]:
78+
"""Return all cached rates for the frontend."""
79+
return {
80+
"fiat": dict(_fiat_rates),
81+
"crypto_usd": dict(_crypto_usd),
82+
"last_updated": _last_fetch,
83+
}
84+
85+
86+
def to_usd(amount: float, currency: str) -> float | None:
87+
"""Convert an amount in *currency* to USD.
88+
89+
Returns None if no rate is available (e.g. unknown token).
90+
"""
91+
if currency == "USD":
92+
return amount
93+
if currency in _crypto_usd:
94+
return amount * _crypto_usd[currency]
95+
return None

app/main.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
from fastapi.templating import Jinja2Templates
2222
from pydantic import BaseModel
2323

24-
from app import auth, catalog, compose_generator, database, orchestrator
24+
from app import auth, catalog, compose_generator, database, exchange_rates, orchestrator
2525

2626
logging.basicConfig(
2727
level=logging.INFO,
@@ -138,7 +138,9 @@ async def lifespan(app: FastAPI):
138138
scheduler.add_job(_run_health_check, "interval", minutes=5, id="health_check")
139139
scheduler.add_job(_check_stale_workers, "interval", minutes=2, id="stale_workers")
140140
scheduler.add_job(_run_data_retention, "interval", hours=24, id="data_retention")
141+
scheduler.add_job(exchange_rates.refresh, "interval", minutes=15, id="exchange_rates")
141142
scheduler.start()
143+
await exchange_rates.refresh()
142144
docker_mode = "direct" if orchestrator.docker_available() else "monitor-only"
143145
logger.info("CashPilot started (Docker: %s)", docker_mode)
144146

@@ -489,6 +491,7 @@ async def api_services_deployed(request: Request) -> list[dict[str, Any]]:
489491
# Get latest earnings per platform for balance display
490492
earnings = await database.get_earnings_summary()
491493
balance_map = {e["platform"]: e["balance"] for e in earnings}
494+
currency_map = {e["platform"]: e["currency"] for e in earnings}
492495

493496
# Get health scores
494497
health_scores = await database.get_health_scores(7)
@@ -542,6 +545,7 @@ async def api_services_deployed(request: Request) -> list[dict[str, Any]]:
542545
"name": svc["name"] if svc else slug,
543546
"container_status": agg["best_status"],
544547
"balance": balance_map.get(slug, 0.0),
548+
"currency": currency_map.get(slug, "USD"),
545549
"cpu": f"{agg['total_cpu']:.2f}",
546550
"memory": f"{agg['total_mem']:.1f} MB",
547551
"image": agg["image"],
@@ -577,6 +581,7 @@ async def api_services_deployed(request: Request) -> list[dict[str, Any]]:
577581
"name": svc["name"] if svc else slug,
578582
"container_status": "external",
579583
"balance": balance_map.get(slug, 0.0),
584+
"currency": currency_map.get(slug, "USD"),
580585
"cpu": "",
581586
"memory": "",
582587
"image": "",
@@ -900,6 +905,14 @@ async def api_earnings_summary(request: Request) -> dict[str, Any]:
900905
_require_auth_api(request)
901906
summary = await database.get_earnings_dashboard_summary()
902907

908+
# Include non-USD balances converted to USD in the total
909+
all_earnings = await database.get_earnings_summary()
910+
for e in all_earnings:
911+
if e["currency"] != "USD":
912+
usd_val = exchange_rates.to_usd(e["balance"], e["currency"])
913+
if usd_val is not None:
914+
summary["total"] = round(summary["total"] + usd_val, 2)
915+
903916
# Count active (running) services — use cached data for instant response
904917
active = 0
905918
try:
@@ -991,6 +1004,13 @@ async def api_collector_alerts(request: Request) -> list[dict[str, str]]:
9911004
return _collector_alerts
9921005

9931006

1007+
@app.get("/api/exchange-rates")
1008+
async def api_exchange_rates(request: Request) -> dict[str, Any]:
1009+
"""Return current exchange rates (fiat + crypto) for frontend conversion."""
1010+
_require_auth_api(request)
1011+
return exchange_rates.get_all()
1012+
1013+
9941014
@app.get("/api/services/{slug}/per-node-earnings")
9951015
async def api_per_node_earnings(request: Request, slug: str) -> list[dict[str, Any]]:
9961016
"""Return per-node earnings for services that support it (e.g. MystNodes)."""

0 commit comments

Comments
 (0)