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
118 changes: 118 additions & 0 deletions allways/cli/das_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
"""Allways Data Access Service (DAS) — swap lookup when resolved data is off-chain.

Environment:
ALLWAYS_DAS_BASE_URL — Base URL for the indexer API (default: test-api.all-ways.io).
"""

from __future__ import annotations

import os
from decimal import Decimal, InvalidOperation
from typing import Any

import requests

from allways.chains import SUPPORTED_CHAINS
from allways.classes import Swap, SwapStatus
from allways.constants import TAO_TO_RAO

DEFAULT_DAS_BASE_URL = 'https://test-api.all-ways.io'

_DAS_STATUS = {
'ACTIVE': SwapStatus.ACTIVE,
'FULFILLED': SwapStatus.FULFILLED,
'COMPLETED': SwapStatus.COMPLETED,
'TIMED_OUT': SwapStatus.TIMED_OUT,
}


def get_das_base_url() -> str:
return os.environ.get('ALLWAYS_DAS_BASE_URL', DEFAULT_DAS_BASE_URL).rstrip('/')


def _str_field(value: Any) -> str:
if value is None:
return ''
return str(value)


def _int_field(value: Any) -> int:
if value is None:
return 0
if isinstance(value, bool):
return 0
if isinstance(value, int):
return value
s = str(value).strip()
if not s:
return 0
return int(s)


def _tao_decimal_to_rao(tao_decimal: str) -> int:
try:
return int(Decimal(tao_decimal.strip()) * Decimal(TAO_TO_RAO))
except (InvalidOperation, ValueError):
return 0


def swap_from_das(raw: dict[str, Any], *, fallback_swap_id: int = 0) -> Swap:
st = str(raw.get('status') or '').upper()
status = _DAS_STATUS.get(st, SwapStatus.ACTIVE)
swap_id = _int_field(raw.get('swapId')) or fallback_swap_id
tao_raw = raw.get('taoAmount')
# taoAmount arrives as a decimal TAO string ("2.5") and needs Decimal precision;
# other integer fields are already rao/sat and fit _int_field.
if isinstance(tao_raw, str):
tao_rao = _tao_decimal_to_rao(tao_raw)
else:
tao_rao = _int_field(tao_raw)
rate_val = raw.get('rate')
rate_str = str(rate_val) if rate_val is not None else ''

return Swap(
id=swap_id,
user_hotkey=_str_field(raw.get('userAddress')),
miner_hotkey=_str_field(raw.get('minerHotkey')),
from_chain=_str_field(raw.get('sourceChain')).lower(),
to_chain=_str_field(raw.get('destChain')).lower(),
from_amount=_int_field(raw.get('sourceAmount')),
to_amount=_int_field(raw.get('destAmount')),
tao_amount=tao_rao,
user_from_address=_str_field(raw.get('userSourceAddress')),
user_to_address=_str_field(raw.get('userDestAddress')),
miner_from_address=_str_field(raw.get('minerSourceAddress')),
miner_to_address=_str_field(raw.get('minerDestAddress')),
rate=rate_str,
from_tx_hash=_str_field(raw.get('sourceTxHash')),
to_tx_hash=_str_field(raw.get('destTxHash')),
status=status,
initiated_block=_int_field(raw.get('initiatedBlock')),
timeout_block=_int_field(raw.get('timeoutBlock')),
fulfilled_block=_int_field(raw.get('fulfilledBlock')),
completed_block=_int_field(raw.get('completedBlock')),
)


def fetch_swap_from_das(swap_id: int, timeout: float = 15.0) -> Swap | None:
url = f'{get_das_base_url()}/swaps/{swap_id}'
try:
r = requests.get(url, headers={'Accept': 'application/json'}, timeout=timeout)
r.raise_for_status()
payload = r.json()
except (requests.RequestException, ValueError):
return None

if not isinstance(payload, dict):
return None
raw = payload.get('swap')
if not isinstance(raw, dict):
return None

try:
sw = swap_from_das(raw, fallback_swap_id=swap_id)
except (OverflowError, TypeError, ValueError):
return None
if sw.from_chain not in SUPPORTED_CHAINS or sw.to_chain not in SUPPORTED_CHAINS:
return None
return sw
14 changes: 13 additions & 1 deletion allways/cli/swap_commands/view.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

from allways.chains import SUPPORTED_CHAINS, get_chain
from allways.classes import SwapStatus
from allways.cli.das_api import fetch_swap_from_das
from allways.cli.help import StyledGroup
from allways.cli.swap_commands.helpers import (
SECONDS_PER_BLOCK,
Expand Down Expand Up @@ -320,20 +321,31 @@ def display_swap(swap, chain_info=True):
def view_swap(swap_id: int, watch: bool):
"""View details of a specific swap.

If the swap is no longer in contract storage (completed or timed out), details
are loaded from the Allways indexer when available (see ALLWAYS_DAS_BASE_URL).

[dim]Examples:
$ alw view swap 42
$ alw view swap 42 --watch[/dim]
"""
_, _, subtensor, client = get_cli_context(need_wallet=False)

das_swap = None
try:
with loading('Reading swap...'):
with loading('Resolving swap...'):
swap = client.get_swap(swap_id)
if not swap:
das_swap = fetch_swap_from_das(swap_id)
except ContractError as e:
console.print(f'[red]Failed to read swap: {e}[/red]')
return

if not swap:
if das_swap:
console.print('\n[dim]Swap no longer on-chain; showing indexer record.[/dim]')
display_swap(das_swap)
return

try:
next_id = client.get_next_swap_id()
except ContractError:
Expand Down
149 changes: 149 additions & 0 deletions tests/test_das_api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
"""Tests for Allways DAS (indexer) → Swap mapping and fetch error handling."""

from __future__ import annotations

import pytest
import requests

import allways.cli.das_api as das_api
from allways.classes import Swap, SwapStatus
from allways.cli.das_api import fetch_swap_from_das, swap_from_das


class _HttpOk:
__slots__ = ('_payload',)

def __init__(self, payload):
self._payload = payload

def raise_for_status(self) -> None:
pass

def json(self):
return self._payload


class _HttpJsonError:
def raise_for_status(self) -> None:
pass

def json(self):
raise ValueError('invalid json')


def test_swap_from_das_maps_indexer_payload():
raw = {
'swapId': '7',
'status': 'FULFILLED',
'userAddress': '5User',
'minerHotkey': '5Miner',
'taoAmount': '2.500000000000000000',
'sourceChain': 'BTC',
'destChain': 'TAO',
'sourceAmount': '100000',
'destAmount': '2500000000',
'rate': '500.0',
'userSourceAddress': 'bc1qaaa',
'userDestAddress': '5bbb',
'minerSourceAddress': 'bc1qminer',
'minerDestAddress': '',
'sourceTxHash': 's' * 64,
'destTxHash': 'd' * 64,
'timeoutBlock': '9000',
'initiatedBlock': '8000',
'fulfilledBlock': '8050',
'completedBlock': '0',
}
s = swap_from_das(raw, fallback_swap_id=99)
assert isinstance(s, Swap)
assert s.id == 7
assert s.status == SwapStatus.FULFILLED
assert s.from_chain == 'btc' and s.to_chain == 'tao'
assert s.from_amount == 100_000 and s.to_amount == 2_500_000_000
assert s.tao_amount == 2_500_000_000
assert s.rate == '500.0'
assert s.user_hotkey == '5User' and s.miner_hotkey == '5Miner'
assert s.user_from_address == 'bc1qaaa' and s.user_to_address == '5bbb'
assert s.miner_from_address == 'bc1qminer' and s.miner_to_address == ''
assert s.from_tx_hash == 's' * 64 and s.to_tx_hash == 'd' * 64
assert s.initiated_block == 8000 and s.timeout_block == 9000
assert s.fulfilled_block == 8050 and s.completed_block == 0
assert s.from_tx_block == 0 and s.to_tx_block == 0


def test_swap_from_das_uses_fallback_id_when_swap_id_missing():
raw = {
'status': 'COMPLETED',
'userAddress': 'u',
'minerHotkey': 'm',
'taoAmount': '0.1',
'sourceChain': 'btc',
'destChain': 'tao',
'sourceAmount': '1',
'destAmount': '1',
'rate': '1',
'userSourceAddress': '',
'userDestAddress': '',
}
assert swap_from_das(raw, fallback_swap_id=42).id == 42


@pytest.mark.parametrize(
'http_response',
[
_HttpOk([]),
_HttpOk({'swap': None}),
_HttpOk({'swap': 'not-a-dict'}),
_HttpOk([{'swap': {}}]),
_HttpJsonError(),
],
)
def test_fetch_swap_from_das_returns_none_on_bad_http_payload(monkeypatch, http_response):
monkeypatch.setattr(das_api.requests, 'get', lambda *_a, **_k: http_response)
assert fetch_swap_from_das(1) is None


def test_fetch_swap_from_das_returns_none_on_network_error(monkeypatch):
def fail(*_a, **_k):
raise requests.ConnectionError('unreachable')

monkeypatch.setattr(das_api.requests, 'get', fail)
assert fetch_swap_from_das(1) is None


def test_fetch_swap_from_das_returns_none_on_unsupported_chains(monkeypatch):
raw = {
'swapId': '1',
'status': 'COMPLETED',
'userAddress': 'u',
'minerHotkey': 'm',
'taoAmount': '1',
'sourceChain': 'eth',
'destChain': 'tao',
'sourceAmount': '1',
'destAmount': '1',
'rate': '1',
'userSourceAddress': '',
'userDestAddress': '',
}
monkeypatch.setattr(das_api.requests, 'get', lambda *_a, **_k: _HttpOk({'swap': raw}))
assert fetch_swap_from_das(1) is None


def test_fetch_swap_from_das_returns_none_on_unparseable_integers(monkeypatch):
raw = {
'swapId': '1',
'status': 'COMPLETED',
'userAddress': 'u',
'minerHotkey': 'm',
'taoAmount': '1',
'sourceChain': 'btc',
'destChain': 'tao',
'sourceAmount': 'not-a-number',
'destAmount': '1',
'rate': '1',
'userSourceAddress': '',
'userDestAddress': '',
}
monkeypatch.setattr(das_api.requests, 'get', lambda *_a, **_k: _HttpOk({'swap': raw}))
assert fetch_swap_from_das(1) is None
Loading