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>
This commit is contained in:
commit
f8cd37ac8c
1823 changed files with 145167 additions and 0 deletions
423
scripts/workers/crypto_offramp/bridge.py
Normal file
423
scripts/workers/crypto_offramp/bridge.py
Normal file
|
|
@ -0,0 +1,423 @@
|
|||
"""Bridge (by Stripe) offramp provider — sole provider per plan.
|
||||
|
||||
Docs: https://apidocs.bridge.xyz/
|
||||
Auth: REST with `Api-Key` header + `Idempotency-Key` header on POSTs.
|
||||
|
||||
Bridge accepts crypto at a deposit address and settles USD to a
|
||||
pre-registered external bank account via RTP / FedNow / wire / ACH.
|
||||
Unlike Coinbase Prime (which is a two-step: sell on exchange, then wire
|
||||
withdrawal), Bridge is a single-transfer model: create a transfer, send
|
||||
coins to its deposit address, and Bridge handles the conversion + payout
|
||||
to the destination bank.
|
||||
|
||||
Env vars:
|
||||
BRIDGE_API_KEY — Bridge API key
|
||||
BRIDGE_API_URL — default https://api.bridge.xyz
|
||||
BRIDGE_RELAY_EXTERNAL_ACCOUNT_ID — pre-registered RelayFi account id
|
||||
BRIDGE_DEVELOPER_FEE_USD — optional platform fee (defaults 0)
|
||||
|
||||
Flow:
|
||||
1. prepare_transfer(coin, target_usd_cents, rail, memo, idempotency_key)
|
||||
→ POST /v0/transfers — returns {transfer_id, deposit_address}
|
||||
2. SHKeeper payout to deposit_address
|
||||
3. Bridge auto-detects deposit, converts, wires USD to Relay via RTP
|
||||
4. status(transfer_id) — poll until state='payment_submitted' | 'completed'
|
||||
|
||||
Supported coins (BRIDGE_ACCEPTS — restricted to what Bridge takes):
|
||||
USDC / USDT / BTC / ETH / SOL (and more) — drops XMR + MATIC/TRX/BNB/DOGE.
|
||||
See `accepts_coins` below. If the SHKeeper webhook delivers an
|
||||
unsupported coin, the orchestrator flips the job to state='manual'.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from . import (
|
||||
ExecutionResult,
|
||||
KycHoldError,
|
||||
OfframpError,
|
||||
ProviderDegradedError,
|
||||
ProviderStatus,
|
||||
Quote,
|
||||
Rail,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BRIDGE_URL = os.environ.get("BRIDGE_API_URL", "https://api.bridge.xyz")
|
||||
|
||||
# Coins Bridge takes at source. Verify against the live API at integration
|
||||
# time — this list reflects Bridge's publicly supported asset menu as of
|
||||
# our integration date. Unsupported coins force manual handling.
|
||||
BRIDGE_ACCEPTS_COINS: set[str] = {"USDC", "USDT", "BTC", "ETH"}
|
||||
|
||||
# Coin → Bridge "currency" slug + default "payment_rail" (source chain)
|
||||
_COIN_TO_BRIDGE: dict[str, dict[str, str]] = {
|
||||
"USDC": {"currency": "usdc", "payment_rail": "ethereum"},
|
||||
"USDT": {"currency": "usdt", "payment_rail": "ethereum"},
|
||||
"BTC": {"currency": "btc", "payment_rail": "bitcoin"},
|
||||
"ETH": {"currency": "eth", "payment_rail": "ethereum"},
|
||||
}
|
||||
|
||||
|
||||
# ── Circuit breaker (module-level) ──────────────────────────────────────
|
||||
|
||||
class _CircuitBreaker:
|
||||
"""Trips if ≥3 provider errors in rolling 10-min window."""
|
||||
ERROR_WINDOW_SEC = 600
|
||||
ERROR_THRESHOLD = 3
|
||||
|
||||
def __init__(self):
|
||||
self._errors: deque[float] = deque()
|
||||
|
||||
def record_error(self):
|
||||
now = time.time()
|
||||
self._errors.append(now)
|
||||
cutoff = now - self.ERROR_WINDOW_SEC
|
||||
while self._errors and self._errors[0] < cutoff:
|
||||
self._errors.popleft()
|
||||
|
||||
def is_tripped(self) -> bool:
|
||||
now = time.time()
|
||||
cutoff = now - self.ERROR_WINDOW_SEC
|
||||
while self._errors and self._errors[0] < cutoff:
|
||||
self._errors.popleft()
|
||||
return len(self._errors) >= self.ERROR_THRESHOLD
|
||||
|
||||
def reset(self):
|
||||
self._errors.clear()
|
||||
|
||||
|
||||
_breaker = _CircuitBreaker()
|
||||
|
||||
|
||||
@dataclass
|
||||
class BridgeTransferPrep:
|
||||
"""Result of creating a Bridge transfer. The deposit_address is where
|
||||
SHKeeper should send coins; the transfer_id is what we poll for status."""
|
||||
transfer_id: str
|
||||
deposit_address: str
|
||||
expected_usd_cents: int
|
||||
source_currency: str
|
||||
source_rail: str
|
||||
destination_rail: Rail
|
||||
raw: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
class BridgeOfframp:
|
||||
"""Sole offramp provider. Uses Bridge's transfers API for both the
|
||||
deposit-instructions and settlement in one atomic object.
|
||||
|
||||
Note on interface fit: Bridge doesn't split sell + wire into separate
|
||||
calls like Coinbase Prime did. We expose a new `prepare_transfer()`
|
||||
method that the orchestrator uses instead of the old
|
||||
`deposit_address()` + `execute()` split. The legacy methods are
|
||||
implemented as thin wrappers so the existing test surface still works.
|
||||
"""
|
||||
|
||||
name = "bridge"
|
||||
accepts_coins = BRIDGE_ACCEPTS_COINS
|
||||
supports_rails: set[Rail] = {"rtp", "wire", "ach"}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
api_key: Optional[str] = None,
|
||||
api_url: Optional[str] = None,
|
||||
relay_external_account_id: Optional[str] = None,
|
||||
developer_fee_usd: Optional[Decimal] = None,
|
||||
):
|
||||
self.api_key = api_key or os.environ.get("BRIDGE_API_KEY", "")
|
||||
self.api_url = api_url or BRIDGE_URL
|
||||
self.relay_external_account_id = (
|
||||
relay_external_account_id
|
||||
or os.environ.get("BRIDGE_RELAY_EXTERNAL_ACCOUNT_ID", "")
|
||||
)
|
||||
self.developer_fee_usd = developer_fee_usd or Decimal(
|
||||
os.environ.get("BRIDGE_DEVELOPER_FEE_USD", "0"),
|
||||
)
|
||||
missing = [
|
||||
k for k, v in [
|
||||
("api_key", self.api_key),
|
||||
("relay_external_account_id", self.relay_external_account_id),
|
||||
] if not v
|
||||
]
|
||||
if missing:
|
||||
logger.warning(
|
||||
"BridgeOfframp: missing env vars %s — calls will fail until configured.",
|
||||
missing,
|
||||
)
|
||||
|
||||
# ── Public interface ────────────────────────────────────────────────
|
||||
|
||||
async def prepare_transfer(
|
||||
self,
|
||||
*,
|
||||
target_usd_cents: int,
|
||||
from_coin: str,
|
||||
rail: Rail = "rtp",
|
||||
memo: Optional[str] = None,
|
||||
idempotency_key: str,
|
||||
) -> BridgeTransferPrep:
|
||||
"""Create a Bridge transfer.
|
||||
|
||||
Idempotent: the Bridge API dedups by Idempotency-Key header, so
|
||||
calling this repeatedly with the same key returns the same
|
||||
transfer. Returns the deposit_address to send coins to.
|
||||
"""
|
||||
self._guard_breaker()
|
||||
from_coin = from_coin.upper()
|
||||
if from_coin not in BRIDGE_ACCEPTS_COINS:
|
||||
raise OfframpError(
|
||||
f"coin {from_coin} not supported by Bridge; "
|
||||
f"supported = {sorted(BRIDGE_ACCEPTS_COINS)}",
|
||||
)
|
||||
rail_lower = rail.lower()
|
||||
if rail_lower not in self.supports_rails:
|
||||
raise OfframpError(f"invalid rail {rail}")
|
||||
|
||||
src_cfg = _COIN_TO_BRIDGE[from_coin]
|
||||
body = {
|
||||
"source": {
|
||||
"currency": src_cfg["currency"],
|
||||
"payment_rail": src_cfg["payment_rail"],
|
||||
},
|
||||
"destination": {
|
||||
"currency": "usd",
|
||||
"payment_rail": rail_lower, # rtp / wire / ach
|
||||
"external_account_id": self.relay_external_account_id,
|
||||
},
|
||||
# amount in USD; Bridge converts the equivalent crypto on deposit
|
||||
"amount": f"{target_usd_cents / 100:.2f}",
|
||||
}
|
||||
if memo:
|
||||
body["destination"]["bank_transfer_memo"] = memo
|
||||
if self.developer_fee_usd > 0:
|
||||
body["developer_fee"] = str(self.developer_fee_usd)
|
||||
|
||||
resp = await self._request(
|
||||
"POST", "/v0/transfers",
|
||||
json_body=body,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
|
||||
# Bridge returns the deposit instructions inside source.deposit_instructions
|
||||
deposit = (resp.get("source") or {}).get("deposit_instructions") or {}
|
||||
address = deposit.get("address") or ""
|
||||
if not address:
|
||||
raise OfframpError(
|
||||
f"Bridge returned no deposit address for {from_coin}; "
|
||||
f"response shape changed or account not onboarded",
|
||||
)
|
||||
transfer_id = resp.get("id") or resp.get("transfer_id") or ""
|
||||
if not transfer_id:
|
||||
raise OfframpError("Bridge returned no transfer id")
|
||||
|
||||
return BridgeTransferPrep(
|
||||
transfer_id=transfer_id,
|
||||
deposit_address=address,
|
||||
expected_usd_cents=target_usd_cents,
|
||||
source_currency=src_cfg["currency"],
|
||||
source_rail=src_cfg["payment_rail"],
|
||||
destination_rail=rail,
|
||||
raw=resp,
|
||||
)
|
||||
|
||||
async def quote(self, amount_coin: Decimal, from_coin: str) -> Quote:
|
||||
"""Light price hint — Bridge gives an atomic price at transfer
|
||||
creation, so this is mostly informational. For USDC/USDT it's
|
||||
always ~1:1."""
|
||||
self._guard_breaker()
|
||||
from_coin = from_coin.upper()
|
||||
if from_coin in ("USDC", "USDT"):
|
||||
return Quote(
|
||||
from_coin=from_coin,
|
||||
amount_coin=amount_coin,
|
||||
estimated_usd_cents=int(amount_coin * Decimal("100")),
|
||||
fx_rate_usd=Decimal("1.00"),
|
||||
fee_usd_cents=0,
|
||||
valid_until=datetime.utcnow() + timedelta(seconds=60),
|
||||
)
|
||||
# For BTC/ETH, use Bridge's pricing endpoint if available; else
|
||||
# fall back to a public CoinGecko fetch. Keep deterministic for
|
||||
# tests — for production integration, replace with Bridge's
|
||||
# /v0/exchange_rates or similar.
|
||||
try:
|
||||
rates = await self._request("GET", "/v0/exchange_rates")
|
||||
pair = f"{from_coin.lower()}-usd"
|
||||
rate_str = (rates.get("rates") or {}).get(pair, {}).get("rate")
|
||||
if rate_str:
|
||||
rate = Decimal(str(rate_str))
|
||||
gross = amount_coin * rate
|
||||
return Quote(
|
||||
from_coin=from_coin,
|
||||
amount_coin=amount_coin,
|
||||
estimated_usd_cents=int(gross * Decimal("100")),
|
||||
fx_rate_usd=rate,
|
||||
fee_usd_cents=0,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("bridge.quote rates fetch failed (%s) — falling back", exc)
|
||||
# Fallback to an approximate market quote (caller can accept 0 rate)
|
||||
return Quote(
|
||||
from_coin=from_coin,
|
||||
amount_coin=amount_coin,
|
||||
estimated_usd_cents=0,
|
||||
fx_rate_usd=Decimal("0"),
|
||||
fee_usd_cents=0,
|
||||
)
|
||||
|
||||
async def status(self, provider_ref: str) -> ProviderStatus:
|
||||
self._guard_breaker()
|
||||
data = await self._request("GET", f"/v0/transfers/{provider_ref}")
|
||||
bridge_state = (data.get("state") or "").lower()
|
||||
mapped = {
|
||||
"awaiting_funds": "submitted",
|
||||
"funds_received": "selling_complete",
|
||||
"in_review": "held",
|
||||
"payment_processing": "wiring",
|
||||
"payment_submitted": "wiring",
|
||||
"payment_processed": "completed",
|
||||
"payment_reviewed": "completed",
|
||||
"completed": "completed",
|
||||
"refunded": "failed",
|
||||
"returned": "failed",
|
||||
"failed": "failed",
|
||||
"undeliverable": "failed",
|
||||
}.get(bridge_state, "submitted")
|
||||
if mapped == "held":
|
||||
logger.warning(
|
||||
"Bridge transfer %s in review (KYC/AML hold possible)",
|
||||
provider_ref,
|
||||
)
|
||||
# Final amount received at destination — Bridge's shape:
|
||||
# {"destination":{"amount":{"value":"299.00"}}} or similar
|
||||
dest = (data.get("destination") or {}).get("amount") or {}
|
||||
value_str = dest.get("value") or data.get("amount") or "0"
|
||||
settled_cents = int(Decimal(str(value_str)) * Decimal("100"))
|
||||
bank_ref = (
|
||||
(data.get("destination") or {}).get("bank_transfer_reference")
|
||||
or data.get("network_reference")
|
||||
)
|
||||
return ProviderStatus(
|
||||
provider_ref=provider_ref,
|
||||
state=mapped,
|
||||
settled_usd_cents=settled_cents if mapped == "completed" else 0,
|
||||
wire_tx_id=bank_ref,
|
||||
error_message=data.get("error_message") or data.get("refund_reason"),
|
||||
raw=data,
|
||||
)
|
||||
|
||||
# ── Legacy Protocol surface (compat with the existing OfframpProvider shape) ──
|
||||
#
|
||||
# The orchestrator was originally designed around a Coinbase-Prime-style
|
||||
# two-step flow (deposit_address, then execute). Bridge is one-step —
|
||||
# prepare_transfer gives you both. For the new crypto_payment_worker
|
||||
# branch ("if provider_name == 'bridge':") we call prepare_transfer
|
||||
# directly. These wrapper methods let the existing Protocol callers
|
||||
# (anything written against CoinbasePrimeOfframp) still function.
|
||||
|
||||
async def deposit_address(self, coin: str) -> str:
|
||||
raise OfframpError(
|
||||
"BridgeOfframp.deposit_address is not supported — "
|
||||
"use prepare_transfer() (which returns both the transfer id and address).",
|
||||
)
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
*,
|
||||
amount_coin: Decimal,
|
||||
from_coin: str,
|
||||
rail: Rail,
|
||||
idempotency_key: str,
|
||||
relay_routing: Optional[str] = None,
|
||||
relay_account: Optional[str] = None,
|
||||
memo: Optional[str] = None,
|
||||
target_usd_cents: Optional[int] = None,
|
||||
) -> ExecutionResult:
|
||||
"""Legacy-Protocol wrapper around prepare_transfer. Returns an
|
||||
ExecutionResult whose provider_ref is the Bridge transfer id."""
|
||||
if target_usd_cents is None:
|
||||
raise OfframpError(
|
||||
"BridgeOfframp.execute requires target_usd_cents (USD-denominated transfer)",
|
||||
)
|
||||
prep = await self.prepare_transfer(
|
||||
target_usd_cents=target_usd_cents,
|
||||
from_coin=from_coin,
|
||||
rail=rail,
|
||||
memo=memo,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
return ExecutionResult(
|
||||
provider_ref=prep.transfer_id,
|
||||
accepted_usd_cents=prep.expected_usd_cents,
|
||||
fill_price_usd=Decimal("0"), # no explicit fill price pre-settlement
|
||||
state="submitted",
|
||||
rail=rail,
|
||||
memo=memo,
|
||||
raw={"bridge_prep": prep.raw, "deposit_address": prep.deposit_address},
|
||||
)
|
||||
|
||||
# ── Internals ───────────────────────────────────────────────────────
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
params: Optional[dict] = None,
|
||||
json_body: Optional[dict] = None,
|
||||
idempotency_key: Optional[str] = None,
|
||||
) -> dict:
|
||||
url = f"{self.api_url}{path}"
|
||||
headers = {
|
||||
"Api-Key": self.api_key,
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
if idempotency_key:
|
||||
headers["Idempotency-Key"] = idempotency_key
|
||||
body = json.dumps(json_body, separators=(",", ":")) if json_body else None
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
resp = await client.request(
|
||||
method, url, headers=headers, params=params, content=body,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
_breaker.record_error()
|
||||
raise OfframpError(f"Bridge request failed: {exc}") from exc
|
||||
|
||||
if resp.status_code == 429 or resp.status_code >= 500:
|
||||
_breaker.record_error()
|
||||
raise OfframpError(
|
||||
f"Bridge {method} {path} → {resp.status_code}: {resp.text[:200]}",
|
||||
)
|
||||
if resp.status_code >= 400:
|
||||
err_text = resp.text[:500]
|
||||
if "kyc" in err_text.lower() or "aml" in err_text.lower() or "review" in err_text.lower():
|
||||
raise KycHoldError(err_text)
|
||||
raise OfframpError(
|
||||
f"Bridge {method} {path} → {resp.status_code}: {err_text}",
|
||||
)
|
||||
try:
|
||||
return resp.json()
|
||||
except ValueError:
|
||||
return {}
|
||||
|
||||
def _guard_breaker(self):
|
||||
if _breaker.is_tripped():
|
||||
raise ProviderDegradedError(
|
||||
"Bridge breaker tripped — ≥3 errors in last 10min. "
|
||||
"Parking in manual review per plan.",
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue