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>
127 lines
4.3 KiB
Python
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 {}
|