new-site/scripts/workers/crypto_offramp/shkeeper_client.py
justin f8cd37ac8c Initial commit — Performance West telecom compliance platform
Includes: API (Express/TypeScript), Astro site, Python workers,
document generators, FCC compliance tools, Canada CRTC formation,
Ansible infrastructure, and deployment scripts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-27 06:54:22 -05:00

127 lines
4.3 KiB
Python

"""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": "<addr>", "amount": "<decimal>"}
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 {}