Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,20 @@ alw --help
- **Validators**: Monitor swaps, verify on-chain transactions, vote on outcomes
- **Smart Contract**: Manages collateral, swap lifecycle, and validator voting
- **CLI**: User interface for posting pairs, managing collateral, and executing swaps
- **swap-api**: Stateless HTTP wrapper around the CLI swap flow, for browser users

## Running swap-api locally

`allways.swap_api` is a thin FastAPI service that wraps the same Python modules the CLI uses (`contract_client`, `commitments`, `dendrite_lite`) and exposes them over HTTP for browser orchestration. It holds no keys, no funds, and no state — see [docs/swap-api/browser-swap-spec.md](../docs/swap-api/browser-swap-spec.md) for the contract.

```bash
WS_ENDPOINT=ws://127.0.0.1:9944 \
NETUID=2 \
CONTRACT_ADDRESS=5DjJmTpcHZvF3aZZEafKBdo3ksmdUSZ8bBBUSFhW3Ce3xf1J \
python -m allways.swap_api
# defaults: SWAP_API_HOST=0.0.0.0 SWAP_API_PORT=8000
curl http://localhost:8000/healthz | jq
```

## Validator Storage Layout

Expand Down
28 changes: 19 additions & 9 deletions allways/cli/dendrite_lite.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,29 +68,39 @@ def discover_validators(
return axons


def broadcast_synapse(
async def broadcast_synapse_async(
wallet: bt.Wallet,
axons: List[bt.AxonInfo],
synapse: bt.Synapse,
timeout: float = 30.0,
) -> list:
"""Broadcast a synapse to all validator axons via dendrite.
"""Async-native broadcast for callers already on an event loop (FastAPI).

Returns list of response synapses.
Uses Dendrite as an async context manager so the underlying aiohttp session
is closed on each call. Long-running services that skipped this would
accumulate sockets — Dendrite's `__del__` cleanup logs a warning and won't
close cleanly without a running loop.
"""
import asyncio

dendrite = bt.Dendrite(wallet=wallet)
timeout = resolve_dendrite_timeout(timeout)
async with bt.Dendrite(wallet=wallet) as dendrite:
return await dendrite(axons=axons, synapse=synapse, deserialize=False, timeout=timeout)


def broadcast_synapse(
wallet: bt.Wallet,
axons: List[bt.AxonInfo],
synapse: bt.Synapse,
timeout: float = 30.0,
) -> list:
"""Broadcast a synapse to all validator axons via dendrite (sync wrapper for CLI)."""
import asyncio

loop = asyncio.new_event_loop()
try:
responses = loop.run_until_complete(dendrite(axons=axons, synapse=synapse, deserialize=False, timeout=timeout))
return loop.run_until_complete(broadcast_synapse_async(wallet, axons, synapse, timeout))
finally:
loop.close()

return responses


def resolve_dendrite_timeout(default: float) -> float:
"""Honor ALW_DENDRITE_TIMEOUT as an override for slow chains (e.g. testnet)."""
Expand Down
1 change: 1 addition & 0 deletions allways/swap_api/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""HTTP wrapper around the CLI swap flow — see docs/swap-api/browser-swap-spec.md."""
15 changes: 15 additions & 0 deletions allways/swap_api/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
"""``python -m allways.swap_api`` — uvicorn entrypoint for local dev and prod."""

import os

import uvicorn


def main() -> None:
port = int(os.environ.get('SWAP_API_PORT', '8000'))
host = os.environ.get('SWAP_API_HOST', '0.0.0.0')
uvicorn.run('allways.swap_api.app:app', host=host, port=port, log_level='info')


if __name__ == '__main__':
main()
37 changes: 37 additions & 0 deletions allways/swap_api/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""FastAPI app factory for swap-api. Wires routers, CORS, and lifespan state."""

from contextlib import asynccontextmanager

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

from allways.swap_api.deps import build_app_state
from allways.swap_api.routes import chains, health, miners, proofs, swap


def create_app() -> FastAPI:
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.allways = build_app_state()
yield

app = FastAPI(
title='Allways Swap API',
description='HTTP wrapper around the swap CLI — see docs/swap-api/browser-swap-spec.md',
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=['*'],
allow_methods=['GET', 'POST', 'OPTIONS'],
allow_headers=['*'],
)
app.include_router(health.router)
app.include_router(chains.router)
app.include_router(miners.router)
app.include_router(proofs.router)
app.include_router(swap.router)
return app


app = create_app()
83 changes: 83 additions & 0 deletions allways/swap_api/deps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
"""Cached dependencies for swap-api routes.

Reads config from env once at startup and holds onto the subtensor, contract
client, and ephemeral wallet for the process lifetime. Routes pull these
through ``get_state()`` rather than rebuilding per-request — creating a fresh
wallet or subtensor on every call thrashes both the keystore and the WS pool.
"""

import os
from dataclasses import dataclass
from typing import Optional

import bittensor as bt
from fastapi import Request

from allways.cli.dendrite_lite import get_ephemeral_wallet
from allways.constants import CONTRACT_ADDRESS as DEFAULT_CONTRACT_ADDRESS
from allways.constants import NETUID_FINNEY
from allways.contract_client import AllwaysContractClient


@dataclass
class AppState:
subtensor: bt.Subtensor
contract_client: AllwaysContractClient
ephemeral_wallet: bt.Wallet
netuid: int
contract_address: str
quorum_timeout_s: float
quorum_poll_interval_s: float


def _env_int(name: str, default: int) -> int:
raw = os.environ.get(name)
if raw is None:
return default
try:
return int(raw)
except ValueError:
return default


def _env_float(name: str, default: float) -> float:
raw = os.environ.get(name)
if raw is None:
return default
try:
return float(raw)
except ValueError:
return default


def build_app_state(subtensor: Optional[bt.Subtensor] = None) -> AppState:
"""Construct the singleton state pinned to the FastAPI app.

Subtensor connection target is the standard bittensor ``network`` arg
(e.g. ``finney``, ``test``, or a ws URL); read from ``WS_ENDPOINT`` to
match the spec's env contract.
"""
network = os.environ.get('WS_ENDPOINT', 'finney')
netuid = _env_int('NETUID', NETUID_FINNEY)
contract_address = os.environ.get('CONTRACT_ADDRESS', DEFAULT_CONTRACT_ADDRESS)
quorum_timeout_s = _env_float('SWAP_API_QUORUM_TIMEOUT_S', 60.0)
quorum_poll_interval_s = _env_float('SWAP_API_QUORUM_POLL_S', 2.0)

if subtensor is None:
subtensor = bt.Subtensor(network=network)
contract_client = AllwaysContractClient(contract_address=contract_address, subtensor=subtensor)
ephemeral_wallet = get_ephemeral_wallet()

return AppState(
subtensor=subtensor,
contract_client=contract_client,
ephemeral_wallet=ephemeral_wallet,
netuid=netuid,
contract_address=contract_address,
quorum_timeout_s=quorum_timeout_s,
quorum_poll_interval_s=quorum_poll_interval_s,
)


def get_state(request: Request) -> AppState:
return request.app.state.allways
90 changes: 90 additions & 0 deletions allways/swap_api/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"""Pydantic request/response shapes for swap-api endpoints (spec §6)."""

from typing import List, Optional, Tuple

from pydantic import BaseModel, Field


class HealthResponse(BaseModel):
ok: bool
chainBlock: Optional[int] = Field(None, description='Current subtensor block, when reachable')
contractAddress: str


class ChainInfo(BaseModel):
id: str
name: str
decimals: int
native_unit: str


class ChainsResponse(BaseModel):
chains: List[ChainInfo]
pairs: List[Tuple[str, str]] = Field(..., description='Every supported (from, to) ordering')


class MinerSummary(BaseModel):
hotkey: str
rate: str = Field(..., description='Canonical rate string for the requested direction')
collateralRao: int
isActive: bool
hasActiveSwap: bool


class BestMinerResponse(BaseModel):
minerHotkey: str
rate: str
expectedOut: int = Field(..., description='Gross dest amount before fee, in smallest unit')
reservationCapacity: int = Field(..., description="Miner's collateral in rao — caps swap size")
sourceAddress: str = Field(..., description='Address users send source funds to')
freshAsOf: int = Field(..., description='Subtensor block when this quote was read')


class ProofMessage(BaseModel):
message: str


class ReserveRequest(BaseModel):
minerHotkey: str
fromChain: str
toChain: str
taoAmount: int
fromAmount: int
toAmount: int
fromAddress: str
fromAddressProof: str
blockAnchor: int
expectedRate: str


class ReserveResponse(BaseModel):
requestHash: str
reservedUntilBlock: int
minerSourceAddress: str
minerHotkey: str


class ConfirmRequest(BaseModel):
requestHash: str = Field(
..., description='Correlation key from /reserve — informational; validators verify on-chain'
)
minerHotkey: str = Field(..., description='Miner that holds the reservation (echoed from /reserve response)')
fromTxHash: str
fromTxProof: str
fromAddress: str
toAddress: str
fromChain: str
toChain: str
fromTxBlock: int = 0


class ConfirmResponse(BaseModel):
accepted: bool
swapId: Optional[int] = None
rejection: Optional[str] = None


class RateChangedError(BaseModel):
code: str = 'RateChanged'
expected: str
actual: str
Empty file.
18 changes: 18 additions & 0 deletions allways/swap_api/routes/chains.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from fastapi import APIRouter

from allways.chains import SUPPORTED_CHAINS
from allways.swap_api.models import ChainInfo, ChainsResponse

router = APIRouter()


@router.get('/chains', response_model=ChainsResponse)
async def list_chains() -> ChainsResponse:
chain_ids = list(SUPPORTED_CHAINS.keys())
return ChainsResponse(
chains=[
ChainInfo(id=c.id, name=c.name, decimals=c.decimals, native_unit=c.native_unit)
for c in SUPPORTED_CHAINS.values()
],
pairs=[(src, dst) for src in chain_ids for dst in chain_ids if src != dst],
)
16 changes: 16 additions & 0 deletions allways/swap_api/routes/health.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from fastapi import APIRouter, Depends
from starlette.concurrency import run_in_threadpool

from allways.swap_api.deps import AppState, get_state
from allways.swap_api.models import HealthResponse

router = APIRouter()


@router.get('/healthz', response_model=HealthResponse)
async def healthz(state: AppState = Depends(get_state)) -> HealthResponse:
try:
block = await run_in_threadpool(state.subtensor.get_current_block)
except Exception:
block = None
return HealthResponse(ok=True, chainBlock=block, contractAddress=state.contract_address)
Loading
Loading