"""SHKeeper client — thin wrapper over the self-hosted SHKeeper REST API. SHKeeper (https://github.com/vsys-host/shkeeper.io) is our crypto payment gateway. It exposes one REST endpoint per coin at {SHKEEPER_URL}/api/v1/{coin}/... with bearer auth via X-Shkeeper-Api-Key header. We use it for two things post-webhook: 1. payout() — withdraw coins from the SHKeeper hot wallet to an external address (either a Coinbase Prime deposit address for offramp, or the cold wallet for sweeps). 2. balance() — read the current hot-wallet balance for sweep sizing. SHKeeper exposes a /payout endpoint; exact shape varies by coin but follows the pattern documented in the project's API README. Confirm shape against the running instance during first real test — we stub with sensible defaults and will iterate. """ from __future__ import annotations import logging import os from dataclasses import dataclass from decimal import Decimal from typing import Optional import httpx logger = logging.getLogger(__name__) SHKEEPER_URL = os.environ.get("SHKEEPER_URL", "http://127.0.0.1:5000") SHKEEPER_API_KEY = os.environ.get("SHKEEPER_API_KEY", "") @dataclass class PayoutResult: withdraw_id: str tx_hash: Optional[str] fee_coin: Decimal = Decimal("0") raw: dict = None # type: ignore[assignment] @dataclass class BalanceResult: balance_coin: Decimal balance_usd: Decimal # SHKeeper returns fiat equivalent on balance updated_at: Optional[str] = None class SHKeeperError(Exception): """Any SHKeeper API failure.""" class SHKeeperClient: def __init__( self, base_url: Optional[str] = None, api_key: Optional[str] = None, ): self.base = base_url or SHKEEPER_URL self.api_key = api_key or SHKEEPER_API_KEY if not self.api_key: logger.warning( "SHKeeperClient: SHKEEPER_API_KEY empty — calls will fail", ) async def payout( self, *, coin: str, destination: str, amount: Decimal, ) -> PayoutResult: """POST /api/v1/{coin}/payout — withdraw to an external address. Request body (per SHKeeper docs): {"destination": "", "amount": ""} Response includes a withdraw_id + eventually a tx_hash. """ path = f"/api/v1/{coin.lower()}/payout" body = { "destination": destination, "amount": str(amount), } resp = await self._request("POST", path, json_body=body) return PayoutResult( withdraw_id=str(resp.get("withdraw_id") or resp.get("id") or ""), tx_hash=resp.get("tx_hash") or resp.get("txid"), fee_coin=Decimal(str(resp.get("fee") or "0")), raw=resp, ) async def balance(self, coin: str) -> BalanceResult: """GET /api/v1/{coin}/balance — current hot-wallet balance.""" path = f"/api/v1/{coin.lower()}/balance" resp = await self._request("GET", path) return BalanceResult( balance_coin=Decimal(str(resp.get("balance") or "0")), balance_usd=Decimal(str(resp.get("balance_usd") or resp.get("fiat_balance") or "0")), updated_at=resp.get("updated_at"), ) async def withdrawal_status(self, coin: str, withdraw_id: str) -> dict: """GET /api/v1/{coin}/payout/{id} — poll for confirmation.""" path = f"/api/v1/{coin.lower()}/payout/{withdraw_id}" return await self._request("GET", path) async def _request( self, method: str, path: str, *, json_body: Optional[dict] = None, ) -> dict: url = f"{self.base}{path}" headers = { "X-Shkeeper-Api-Key": self.api_key, "Content-Type": "application/json", } try: async with httpx.AsyncClient(timeout=30.0) as client: resp = await client.request(method, url, headers=headers, json=json_body) except httpx.HTTPError as exc: raise SHKeeperError(f"SHKeeper request failed: {exc}") from exc if resp.status_code >= 400: raise SHKeeperError( f"SHKeeper {method} {path} → {resp.status_code}: {resp.text[:300]}", ) try: return resp.json() except ValueError: return {}