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>
423 lines
16 KiB
Python
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.",
|
|
)
|