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