new-site/scripts/workers/crypto_offramp/bridge.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

423 lines
16 KiB
Python

"""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.",
)