crypto_watcher: dist clients for XMR/BTC/LTC/DOGE via erldistpy
Drop-in alternatives to MoneroClient + DogecoinClient (plus first-class
BTC + LTC clients) that route wallet RPC through Wallet.Service on
cammy via Erlang dist instead of hitting daemons directly.
Why both transports coexist:
- Direct-HTTP clients keep working unchanged — no shop is forced over
- Each shop opts in by config; once a shop's on dist, its daemons
don't have to live on the MPS droplet
- End goal: drain wallet daemons off prod MPS, right-size hosting
What's here:
- ErlangDistMoneroClient mirrors MoneroClient's public surface
(create_subaddress, get_transfers_for_subaddr, get_height,
is_synced, get_sync_status, get_balance, sweep_subaddress,
transfer, plus new sweep_subaddress_with_details +
get_tx_confirmations helpers)
- ErlangDistBitcoinClient / ErlangDistLitecoinClient /
ErlangDistDogecoinClient share a UTXO base class, identical to
DogecoinClient's surface (getnewaddress, getreceivedbyaddress,
sendmany, sendtoaddress, gettransaction, listtransactions,
is_synced, get_sync_status, plus new get_tx_confirmations)
- refund_with_fee_split — one transaction for customer refund plus
optional shop fee output, covers all four coins
- refund_economically_viable — pure helper matching
Wallet.Service.refund_economically_viable?/3 default ($0.069)
Wire shape against Wallet.Service handle_call clauses:
{:monero, :function_name, [args...]}
{:utxo, :coin_atom, :function_name, [args...]}
{:refund_with_fee_split, coin, refund_addr, refund_amt, shop_addr, shop_amt, opts}
Lazy import: erldistpy is only imported when a dist client is
constructed, so MPS deploys that stay on HTTP transport don't need it
installed. Secrets discipline matches existing clients — cookie + TLS
material are paths the caller resolves; no PEM bytes enter Python.
44 unit tests covering each client's tuple shape + reply decoding +
error mapping (CallTimeout → WalletDistError, NodeError →
WalletDistError). The cross-stack wire contract is already verified
in wallet.unsandbox.com's test/functional suite (Python erldistpy →
Erlang dist → Wallet.Service → mock daemons → reply back), so MPS-side
tests focus on the adapter.
Existing 110 crypto_watcher tests untouched and still green.
Not in this commit:
- No watcher orchestration changes (still uses direct clients)
- No CryptoProcessor model column / Alembic migration
- No factory wiring per-shop transport selection
That's the next discrete ticket so each piece is reviewable on its own.
This commit is contained in:
parent
6e37c8b233
commit
eae627e065
2 changed files with 1102 additions and 0 deletions
655
make_post_sell/lib/crypto_watcher/erldist_clients.py
Normal file
655
make_post_sell/lib/crypto_watcher/erldist_clients.py
Normal file
|
|
@ -0,0 +1,655 @@
|
||||||
|
"""
|
||||||
|
Native Erlang distribution clients for Wallet.Service on wallet@cammy.
|
||||||
|
|
||||||
|
Drop-in alternatives to ``MoneroClient`` and ``DogecoinClient`` (and new
|
||||||
|
``BitcoinClient`` / ``LitecoinClient`` surfaces) that route wallet RPC
|
||||||
|
through Wallet.Service on cammy via Erlang dist (``erldistpy``) instead
|
||||||
|
of hitting daemons directly. The public method signatures mirror the
|
||||||
|
existing direct-HTTP clients so the watcher loop can swap transports
|
||||||
|
per shop without further changes.
|
||||||
|
|
||||||
|
Why both transports exist:
|
||||||
|
- Existing direct-HTTP clients (this file's siblings) talk to
|
||||||
|
daemons co-located with the MPS Pyramid process. Self-contained,
|
||||||
|
no external dependency.
|
||||||
|
- These dist clients call into Wallet.Service on cammy, which owns
|
||||||
|
the daemon credentials. Lets us drain wallet daemons off the MPS
|
||||||
|
droplet to right-size hosting; each shop opts in by setting a
|
||||||
|
transport config.
|
||||||
|
|
||||||
|
Lazy import: ``erldistpy`` is only imported when a dist client is
|
||||||
|
constructed. MPS deploys that stick with the HTTP transport never need
|
||||||
|
erldistpy installed.
|
||||||
|
|
||||||
|
Operation Voyeur: ``cookie`` is a string the caller has already read
|
||||||
|
from a file path. TLS material (``tls_cert``, ``tls_key``, ``tls_ca``)
|
||||||
|
are file paths loaded by OpenSSL — no PEM bytes enter Python memory.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
|
||||||
|
WALLET_SERVICE_NAME = "Elixir.Wallet.Service"
|
||||||
|
|
||||||
|
|
||||||
|
def _make_node(
|
||||||
|
*,
|
||||||
|
our_name: str,
|
||||||
|
peer_name: str,
|
||||||
|
cookie: str,
|
||||||
|
peer_host: str = "localhost",
|
||||||
|
connect_timeout: float = 5.0,
|
||||||
|
tls_cert: Optional[str] = None,
|
||||||
|
tls_key: Optional[str] = None,
|
||||||
|
tls_ca: Optional[str] = None,
|
||||||
|
):
|
||||||
|
"""Open an ``erldistpy.Node`` against the wallet node.
|
||||||
|
|
||||||
|
Lazy import so ``erldistpy`` is only required when a dist client is
|
||||||
|
actually constructed.
|
||||||
|
"""
|
||||||
|
from erldistpy import Node, make_dist_tls_context
|
||||||
|
|
||||||
|
tls_context = None
|
||||||
|
if tls_cert and tls_key and tls_ca:
|
||||||
|
tls_context = make_dist_tls_context(cert=tls_cert, key=tls_key, ca=tls_ca)
|
||||||
|
|
||||||
|
return Node(
|
||||||
|
our_name=our_name,
|
||||||
|
peer_name=peer_name,
|
||||||
|
cookie=cookie,
|
||||||
|
peer_host=peer_host,
|
||||||
|
connect_timeout=connect_timeout,
|
||||||
|
tls_context=tls_context,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _atom(name: str):
|
||||||
|
"""Build an ETF atom term. Lazy import for the same reason as Node."""
|
||||||
|
from erldistpy import Atom
|
||||||
|
|
||||||
|
return Atom(name)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_python(value: Any) -> Any:
|
||||||
|
"""Translate ETF-decoded terms back to JSON-shaped Python values.
|
||||||
|
|
||||||
|
Atoms decode to their name string. Binaries to utf-8 strings when
|
||||||
|
decodable (fall back to raw bytes). Maps/lists/tuples recurse so
|
||||||
|
the watcher sees the same dict shape it gets from the HTTP client.
|
||||||
|
"""
|
||||||
|
from erldistpy import Atom
|
||||||
|
|
||||||
|
if isinstance(value, Atom):
|
||||||
|
return value.name
|
||||||
|
if isinstance(value, bytes):
|
||||||
|
try:
|
||||||
|
return value.decode("utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
return value
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {_to_python(k): _to_python(v) for k, v in value.items()}
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [_to_python(x) for x in value]
|
||||||
|
if isinstance(value, tuple):
|
||||||
|
return tuple(_to_python(x) for x in value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _unwrap_ok(reply: Any, what: str) -> Any:
|
||||||
|
"""Unwrap ``{:ok, value}`` or raise on ``{:error, ...}``.
|
||||||
|
|
||||||
|
Wallet.Service replies match one of these shapes:
|
||||||
|
- ``(Atom("ok"), value)`` → return ``_to_python(value)``
|
||||||
|
- ``(Atom("error"), reason)`` → raise ``WalletDistError``
|
||||||
|
- bare value (e.g. integer/string for direct RPC passthroughs) →
|
||||||
|
return as-is after ``_to_python``
|
||||||
|
"""
|
||||||
|
if isinstance(reply, tuple) and len(reply) == 2:
|
||||||
|
head = reply[0]
|
||||||
|
from erldistpy import Atom
|
||||||
|
|
||||||
|
if isinstance(head, Atom):
|
||||||
|
if head.name == "ok":
|
||||||
|
return _to_python(reply[1])
|
||||||
|
if head.name == "error":
|
||||||
|
detail = _to_python(reply[1])
|
||||||
|
raise WalletDistError(f"{what} failed: {detail!r}")
|
||||||
|
# Some Wallet.Service handlers return the raw value (no ok-tuple),
|
||||||
|
# e.g. utxo passthroughs that return what bitcoind sent.
|
||||||
|
return _to_python(reply)
|
||||||
|
|
||||||
|
|
||||||
|
class WalletDistError(RuntimeError):
|
||||||
|
"""Wallet.Service returned an ``{:error, _}`` tuple."""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Connection wrapper — holds the erldistpy.Node and exposes call(msg)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class _DistConn:
|
||||||
|
"""Thin wrapper so client classes can share one Node instance.
|
||||||
|
|
||||||
|
Use ``open()`` to construct, ``close()`` when done. Each ``.call()``
|
||||||
|
forwards to the Wallet.Service registered process and translates
|
||||||
|
timeouts to a domain error.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
our_name: str,
|
||||||
|
peer_name: str,
|
||||||
|
cookie: str,
|
||||||
|
peer_host: str = "localhost",
|
||||||
|
call_timeout: float = 15.0,
|
||||||
|
connect_timeout: float = 5.0,
|
||||||
|
tls_cert: Optional[str] = None,
|
||||||
|
tls_key: Optional[str] = None,
|
||||||
|
tls_ca: Optional[str] = None,
|
||||||
|
):
|
||||||
|
self._call_timeout = call_timeout
|
||||||
|
self._node = _make_node(
|
||||||
|
our_name=our_name,
|
||||||
|
peer_name=peer_name,
|
||||||
|
cookie=cookie,
|
||||||
|
peer_host=peer_host,
|
||||||
|
connect_timeout=connect_timeout,
|
||||||
|
tls_cert=tls_cert,
|
||||||
|
tls_key=tls_key,
|
||||||
|
tls_ca=tls_ca,
|
||||||
|
)
|
||||||
|
|
||||||
|
def call(self, msg: Any, timeout: Optional[float] = None) -> Any:
|
||||||
|
from erldistpy import CallTimeout, NodeError
|
||||||
|
|
||||||
|
try:
|
||||||
|
return self._node.call(
|
||||||
|
WALLET_SERVICE_NAME, msg, timeout=timeout or self._call_timeout
|
||||||
|
)
|
||||||
|
except CallTimeout as e:
|
||||||
|
raise WalletDistError(f"wallet RPC timed out: {e}") from e
|
||||||
|
except NodeError as e:
|
||||||
|
raise WalletDistError(f"wallet RPC error: {e}") from e
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self._node.close()
|
||||||
|
|
||||||
|
def __enter__(self) -> "_DistConn":
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_a) -> None:
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Monero
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class ErlangDistMoneroClient:
|
||||||
|
"""Drop-in replacement for ``MoneroClient`` over Erlang dist.
|
||||||
|
|
||||||
|
Mirrors ``MoneroClient``'s public surface so the crypto_watcher loop
|
||||||
|
can swap transports without code changes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
our_name: str,
|
||||||
|
peer_name: str,
|
||||||
|
cookie: str,
|
||||||
|
peer_host: str = "localhost",
|
||||||
|
call_timeout: float = 15.0,
|
||||||
|
connect_timeout: float = 5.0,
|
||||||
|
tls_cert: Optional[str] = None,
|
||||||
|
tls_key: Optional[str] = None,
|
||||||
|
tls_ca: Optional[str] = None,
|
||||||
|
):
|
||||||
|
self._conn = _DistConn(
|
||||||
|
our_name=our_name,
|
||||||
|
peer_name=peer_name,
|
||||||
|
cookie=cookie,
|
||||||
|
peer_host=peer_host,
|
||||||
|
call_timeout=call_timeout,
|
||||||
|
connect_timeout=connect_timeout,
|
||||||
|
tls_cert=tls_cert,
|
||||||
|
tls_key=tls_key,
|
||||||
|
tls_ca=tls_ca,
|
||||||
|
)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self._conn.close()
|
||||||
|
|
||||||
|
def __enter__(self) -> "ErlangDistMoneroClient":
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_a) -> None:
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
# ------ Address creation
|
||||||
|
def create_subaddress(
|
||||||
|
self, account_index: int = 0, label: Optional[str] = None
|
||||||
|
) -> Tuple[str, int]:
|
||||||
|
msg = (
|
||||||
|
_atom("monero"),
|
||||||
|
_atom("create_subaddress"),
|
||||||
|
[account_index, label.encode("utf-8") if label else None],
|
||||||
|
)
|
||||||
|
result = _unwrap_ok(self._conn.call(msg), "create_subaddress")
|
||||||
|
# Wallet.Service returns {:ok, {address, index}} → (str, int) tuple
|
||||||
|
if isinstance(result, tuple) and len(result) == 2:
|
||||||
|
address, index = result
|
||||||
|
return (
|
||||||
|
address if isinstance(address, str) else address.decode("utf-8"),
|
||||||
|
int(index),
|
||||||
|
)
|
||||||
|
raise WalletDistError(f"create_subaddress: unexpected reply shape {result!r}")
|
||||||
|
|
||||||
|
# ------ Payment polling
|
||||||
|
def get_transfers_for_subaddr(
|
||||||
|
self, account_index: int, subaddr_indices: List[int]
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
msg = (
|
||||||
|
_atom("monero"),
|
||||||
|
_atom("get_transfers_for_subaddr"),
|
||||||
|
[account_index, list(subaddr_indices)],
|
||||||
|
)
|
||||||
|
return _unwrap_ok(self._conn.call(msg), "get_transfers_for_subaddr") or {}
|
||||||
|
|
||||||
|
# ------ Balance
|
||||||
|
def get_balance(
|
||||||
|
self, account_index: int = 0, subaddress_indices: Optional[List[int]] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
msg = (
|
||||||
|
_atom("monero"),
|
||||||
|
_atom("get_balance"),
|
||||||
|
[account_index, list(subaddress_indices or [])],
|
||||||
|
)
|
||||||
|
return _unwrap_ok(self._conn.call(msg), "get_balance") or {}
|
||||||
|
|
||||||
|
# ------ Height + sync
|
||||||
|
def get_height(self) -> int:
|
||||||
|
msg = (_atom("monero"), _atom("get_height"), [])
|
||||||
|
result = _unwrap_ok(self._conn.call(msg), "get_height")
|
||||||
|
return int(result) if result is not None else 0
|
||||||
|
|
||||||
|
def is_synced(self) -> bool:
|
||||||
|
try:
|
||||||
|
return self.get_height() > 0
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_sync_status(self) -> dict:
|
||||||
|
try:
|
||||||
|
h = self.get_height()
|
||||||
|
synced = h > 0
|
||||||
|
return {
|
||||||
|
"wallet_height": h,
|
||||||
|
"synced": synced,
|
||||||
|
"sync_percentage": 100.0 if synced else 0.0,
|
||||||
|
"remote_node": True,
|
||||||
|
"ready": synced,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"wallet_height": 0,
|
||||||
|
"synced": False,
|
||||||
|
"sync_percentage": 0.0,
|
||||||
|
"remote_node": True,
|
||||||
|
"ready": False,
|
||||||
|
"error": str(e),
|
||||||
|
}
|
||||||
|
|
||||||
|
def refresh(self) -> Any:
|
||||||
|
msg = (_atom("monero"), _atom("refresh"), [])
|
||||||
|
return _unwrap_ok(self._conn.call(msg, timeout=60.0), "refresh")
|
||||||
|
|
||||||
|
# ------ Sweep
|
||||||
|
def sweep_subaddress(
|
||||||
|
self,
|
||||||
|
account_index: int,
|
||||||
|
subaddress_index: int,
|
||||||
|
destination: str,
|
||||||
|
priority: int = 0,
|
||||||
|
) -> str:
|
||||||
|
msg = (
|
||||||
|
_atom("monero"),
|
||||||
|
_atom("sweep_subaddress"),
|
||||||
|
[account_index, subaddress_index, destination.encode("utf-8"), priority],
|
||||||
|
)
|
||||||
|
result = _unwrap_ok(self._conn.call(msg, timeout=30.0), "sweep_subaddress")
|
||||||
|
return result if isinstance(result, str) else str(result)
|
||||||
|
|
||||||
|
def sweep_subaddress_with_details(
|
||||||
|
self,
|
||||||
|
account_index: int,
|
||||||
|
subaddress_index: int,
|
||||||
|
destination: str,
|
||||||
|
priority: int = 0,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Returns ``{"tx_hash": str, "amount": int}`` for fee accounting.
|
||||||
|
|
||||||
|
Use this instead of ``sweep_subaddress`` whenever you need the
|
||||||
|
actual swept amount (e.g. restocking-fee math against the
|
||||||
|
on-chain amount after network fees).
|
||||||
|
"""
|
||||||
|
msg = (
|
||||||
|
_atom("monero"),
|
||||||
|
_atom("sweep_subaddress_with_details"),
|
||||||
|
[account_index, subaddress_index, destination.encode("utf-8"), priority],
|
||||||
|
)
|
||||||
|
return _unwrap_ok(
|
||||||
|
self._conn.call(msg, timeout=30.0), "sweep_subaddress_with_details"
|
||||||
|
) or {}
|
||||||
|
|
||||||
|
# ------ Transfer (refunds)
|
||||||
|
def transfer(
|
||||||
|
self,
|
||||||
|
destinations: List[Dict[str, Any]],
|
||||||
|
account_index: int = 0,
|
||||||
|
priority: int = 0,
|
||||||
|
get_tx_key: bool = True,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""``destinations``: ``[{"amount": int_atomic, "address": str}, ...]``."""
|
||||||
|
encoded = [
|
||||||
|
{b"amount": int(d["amount"]), b"address": d["address"].encode("utf-8")}
|
||||||
|
for d in destinations
|
||||||
|
]
|
||||||
|
msg = (
|
||||||
|
_atom("monero"),
|
||||||
|
_atom("transfer"),
|
||||||
|
[
|
||||||
|
encoded,
|
||||||
|
account_index,
|
||||||
|
[(_atom("priority"), priority), (_atom("get_tx_key"), get_tx_key)],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return _unwrap_ok(self._conn.call(msg, timeout=30.0), "transfer") or {}
|
||||||
|
|
||||||
|
# ------ Confirmation tracking (new helper)
|
||||||
|
def get_tx_confirmations(self, tx_hash: str, account_index: int = 0) -> int:
|
||||||
|
msg = (
|
||||||
|
_atom("monero"),
|
||||||
|
_atom("get_tx_confirmations"),
|
||||||
|
[tx_hash.encode("utf-8"), account_index],
|
||||||
|
)
|
||||||
|
result = _unwrap_ok(self._conn.call(msg), "get_tx_confirmations")
|
||||||
|
return int(result) if result is not None else 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# UTXO coins — shared base, one subclass per coin
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class _ErlangDistUtxoClient:
|
||||||
|
"""Shared base for Bitcoin / Litecoin / Dogecoin dist clients.
|
||||||
|
|
||||||
|
All three speak the same bitcoind-style RPC contract; only the coin
|
||||||
|
atom (``:btc`` / ``:ltc`` / ``:doge``) differs. Subclasses set
|
||||||
|
``COIN_ATOM`` to identify themselves to Wallet.Service.
|
||||||
|
"""
|
||||||
|
|
||||||
|
COIN_ATOM: str = "" # overridden by subclass
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
our_name: str,
|
||||||
|
peer_name: str,
|
||||||
|
cookie: str,
|
||||||
|
peer_host: str = "localhost",
|
||||||
|
call_timeout: float = 15.0,
|
||||||
|
connect_timeout: float = 5.0,
|
||||||
|
tls_cert: Optional[str] = None,
|
||||||
|
tls_key: Optional[str] = None,
|
||||||
|
tls_ca: Optional[str] = None,
|
||||||
|
):
|
||||||
|
if not self.COIN_ATOM:
|
||||||
|
raise TypeError(
|
||||||
|
f"{type(self).__name__}: COIN_ATOM must be set on subclass"
|
||||||
|
)
|
||||||
|
self._conn = _DistConn(
|
||||||
|
our_name=our_name,
|
||||||
|
peer_name=peer_name,
|
||||||
|
cookie=cookie,
|
||||||
|
peer_host=peer_host,
|
||||||
|
call_timeout=call_timeout,
|
||||||
|
connect_timeout=connect_timeout,
|
||||||
|
tls_cert=tls_cert,
|
||||||
|
tls_key=tls_key,
|
||||||
|
tls_ca=tls_ca,
|
||||||
|
)
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
self._conn.close()
|
||||||
|
|
||||||
|
def __enter__(self) -> "_ErlangDistUtxoClient":
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_a) -> None:
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
def _utxo_call(self, function: str, args: List[Any], timeout: Optional[float] = None) -> Any:
|
||||||
|
msg = (
|
||||||
|
_atom("utxo"),
|
||||||
|
_atom(self.COIN_ATOM),
|
||||||
|
_atom(function),
|
||||||
|
args,
|
||||||
|
)
|
||||||
|
return _unwrap_ok(self._conn.call(msg, timeout=timeout), function)
|
||||||
|
|
||||||
|
# ------ Wallet management
|
||||||
|
def getnewaddress(self, label: str = "") -> str:
|
||||||
|
result = self._utxo_call("getnewaddress", [label.encode("utf-8")])
|
||||||
|
return result if isinstance(result, str) else str(result)
|
||||||
|
|
||||||
|
def getaddressesbylabel(self, label: str) -> Dict[str, Any]:
|
||||||
|
return self._utxo_call("getaddressesbylabel", [label.encode("utf-8")]) or {}
|
||||||
|
|
||||||
|
def validateaddress(self, address: str) -> Dict[str, Any]:
|
||||||
|
return self._utxo_call("validateaddress", [address.encode("utf-8")]) or {}
|
||||||
|
|
||||||
|
# ------ Balances + history
|
||||||
|
def getbalance(self) -> float:
|
||||||
|
result = self._utxo_call("getbalance", [])
|
||||||
|
return float(result) if result is not None else 0.0
|
||||||
|
|
||||||
|
def getreceivedbyaddress(self, address: str, minconf: int = 0) -> float:
|
||||||
|
result = self._utxo_call(
|
||||||
|
"getreceivedbyaddress", [address.encode("utf-8"), minconf]
|
||||||
|
)
|
||||||
|
return float(result) if result is not None else 0.0
|
||||||
|
|
||||||
|
def listtransactions(
|
||||||
|
self, label: str = "*", count: int = 10, skip: int = 0
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
return self._utxo_call(
|
||||||
|
"listtransactions", [label.encode("utf-8"), count, skip]
|
||||||
|
) or []
|
||||||
|
|
||||||
|
def gettransaction(self, txid: str) -> Dict[str, Any]:
|
||||||
|
return self._utxo_call("gettransaction", [txid.encode("utf-8")]) or {}
|
||||||
|
|
||||||
|
# ------ Sending
|
||||||
|
def sendtoaddress(self, address: str, amount: float, comment: str = "") -> str:
|
||||||
|
opts = []
|
||||||
|
if comment:
|
||||||
|
opts.append((_atom("comment"), comment.encode("utf-8")))
|
||||||
|
result = self._utxo_call(
|
||||||
|
"sendtoaddress",
|
||||||
|
[address.encode("utf-8"), float(amount), opts],
|
||||||
|
timeout=30.0,
|
||||||
|
)
|
||||||
|
return result if isinstance(result, str) else str(result)
|
||||||
|
|
||||||
|
def sendmany(
|
||||||
|
self,
|
||||||
|
from_label: str,
|
||||||
|
addresses_amounts: Dict[str, float],
|
||||||
|
minconf: int = 1,
|
||||||
|
comment: str = "",
|
||||||
|
) -> str:
|
||||||
|
outputs = {
|
||||||
|
addr.encode("utf-8"): float(amt) for addr, amt in addresses_amounts.items()
|
||||||
|
}
|
||||||
|
opts = [(_atom("minconf"), minconf), (_atom("from_account"), from_label.encode("utf-8"))]
|
||||||
|
if comment:
|
||||||
|
opts.append((_atom("comment"), comment.encode("utf-8")))
|
||||||
|
result = self._utxo_call("sendmany", [outputs, opts], timeout=30.0)
|
||||||
|
return result if isinstance(result, str) else str(result)
|
||||||
|
|
||||||
|
# ------ Blockchain status
|
||||||
|
def getblockcount(self) -> int:
|
||||||
|
# Some Wallet.Service handlers may not expose getblockcount —
|
||||||
|
# fall back to getblockchaininfo if it errors.
|
||||||
|
try:
|
||||||
|
result = self._utxo_call("getblockcount", [])
|
||||||
|
return int(result) if result is not None else 0
|
||||||
|
except WalletDistError:
|
||||||
|
info = self.getblockchaininfo()
|
||||||
|
return int(info.get("blocks", 0))
|
||||||
|
|
||||||
|
def getblockchaininfo(self) -> Dict[str, Any]:
|
||||||
|
return self._utxo_call("getblockchaininfo", []) or {}
|
||||||
|
|
||||||
|
def is_synced(self) -> bool:
|
||||||
|
try:
|
||||||
|
info = self.getblockchaininfo()
|
||||||
|
blocks = int(info.get("blocks") or 0)
|
||||||
|
headers = int(info.get("headers") or 0)
|
||||||
|
return blocks > 0 and blocks >= headers - 1
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_sync_status(self) -> dict:
|
||||||
|
try:
|
||||||
|
info = self.getblockchaininfo()
|
||||||
|
blocks = int(info.get("blocks") or 0)
|
||||||
|
headers = int(info.get("headers") or 0)
|
||||||
|
synced = blocks > 0 and blocks >= headers - 1
|
||||||
|
return {
|
||||||
|
"blocks": blocks,
|
||||||
|
"headers": headers,
|
||||||
|
"synced": synced,
|
||||||
|
"sync_percentage": 100.0 * blocks / headers if headers else 0.0,
|
||||||
|
"ready": synced,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"blocks": 0,
|
||||||
|
"headers": 0,
|
||||||
|
"synced": False,
|
||||||
|
"sync_percentage": 0.0,
|
||||||
|
"ready": False,
|
||||||
|
"error": str(e),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------ Confirmation tracking (new helper)
|
||||||
|
def get_tx_confirmations(self, txid: str) -> int:
|
||||||
|
result = self._utxo_call("get_tx_confirmations", [txid.encode("utf-8")])
|
||||||
|
return int(result) if result is not None else 0
|
||||||
|
|
||||||
|
|
||||||
|
class ErlangDistBitcoinClient(_ErlangDistUtxoClient):
|
||||||
|
COIN_ATOM = "btc"
|
||||||
|
|
||||||
|
|
||||||
|
class ErlangDistLitecoinClient(_ErlangDistUtxoClient):
|
||||||
|
COIN_ATOM = "ltc"
|
||||||
|
|
||||||
|
|
||||||
|
class ErlangDistDogecoinClient(_ErlangDistUtxoClient):
|
||||||
|
COIN_ATOM = "doge"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Cross-coin helpers (operate on coin_type parameter, not tied to one client)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def refund_with_fee_split(
|
||||||
|
conn: _DistConn,
|
||||||
|
coin_type: str,
|
||||||
|
refund_address: str,
|
||||||
|
refund_amount_atomic: int,
|
||||||
|
shop_address: Optional[str] = None,
|
||||||
|
shop_amount_atomic: int = 0,
|
||||||
|
opts: Optional[List[Tuple[Any, Any]]] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Single-transaction customer refund + optional shop fee output.
|
||||||
|
|
||||||
|
``coin_type``: one of ``"xmr"``, ``"btc"``, ``"ltc"``, ``"doge"``.
|
||||||
|
|
||||||
|
Atomic amounts (piconero / satoshi / litoshi / koinu). Pass
|
||||||
|
``shop_amount_atomic=0`` for a refund without a fee split.
|
||||||
|
|
||||||
|
Returns ``{"tx_hash": str}`` on success or raises ``WalletDistError``.
|
||||||
|
"""
|
||||||
|
msg = (
|
||||||
|
_atom("refund_with_fee_split"),
|
||||||
|
_atom(coin_type),
|
||||||
|
refund_address.encode("utf-8"),
|
||||||
|
int(refund_amount_atomic),
|
||||||
|
shop_address.encode("utf-8") if shop_address else None,
|
||||||
|
int(shop_amount_atomic),
|
||||||
|
opts or [],
|
||||||
|
)
|
||||||
|
return _unwrap_ok(conn.call(msg, timeout=30.0), "refund_with_fee_split") or {}
|
||||||
|
|
||||||
|
|
||||||
|
def refund_economically_viable(
|
||||||
|
refund_amount_coin: Any,
|
||||||
|
usd_per_coin: Optional[Any],
|
||||||
|
min_usd: Any = Decimal("0.069"),
|
||||||
|
) -> bool:
|
||||||
|
"""Pure helper — same math as Wallet.Service.refund_economically_viable?/3.
|
||||||
|
|
||||||
|
Doesn't touch the network. Lives here so callers don't have to round-
|
||||||
|
trip a trivial decision through dist. Wallet.Service exposes the
|
||||||
|
same predicate so the Elixir side has a single source of truth, but
|
||||||
|
Python callers can short-circuit.
|
||||||
|
|
||||||
|
Default ``min_usd`` matches MPS's ``MINIMUM_VIABLE_REFUND_USD``.
|
||||||
|
"""
|
||||||
|
if usd_per_coin is None:
|
||||||
|
return True
|
||||||
|
refund = refund_amount_coin if isinstance(refund_amount_coin, Decimal) else Decimal(str(refund_amount_coin))
|
||||||
|
rate = usd_per_coin if isinstance(usd_per_coin, Decimal) else Decimal(str(usd_per_coin))
|
||||||
|
threshold = min_usd if isinstance(min_usd, Decimal) else Decimal(str(min_usd))
|
||||||
|
return (refund * rate) >= threshold
|
||||||
|
|
||||||
|
|
||||||
|
def open_wallet_conn(
|
||||||
|
*,
|
||||||
|
our_name: str,
|
||||||
|
peer_name: str,
|
||||||
|
cookie: str,
|
||||||
|
peer_host: str = "localhost",
|
||||||
|
call_timeout: float = 15.0,
|
||||||
|
connect_timeout: float = 5.0,
|
||||||
|
tls_cert: Optional[str] = None,
|
||||||
|
tls_key: Optional[str] = None,
|
||||||
|
tls_ca: Optional[str] = None,
|
||||||
|
) -> _DistConn:
|
||||||
|
"""Open a connection for callers that want to use ``refund_with_fee_split``
|
||||||
|
directly (without holding a per-coin client). Caller closes it with
|
||||||
|
``.close()`` or uses it as a context manager."""
|
||||||
|
return _DistConn(
|
||||||
|
our_name=our_name,
|
||||||
|
peer_name=peer_name,
|
||||||
|
cookie=cookie,
|
||||||
|
peer_host=peer_host,
|
||||||
|
call_timeout=call_timeout,
|
||||||
|
connect_timeout=connect_timeout,
|
||||||
|
tls_cert=tls_cert,
|
||||||
|
tls_key=tls_key,
|
||||||
|
tls_ca=tls_ca,
|
||||||
|
)
|
||||||
447
make_post_sell/tests/test_erldist_clients.py
Normal file
447
make_post_sell/tests/test_erldist_clients.py
Normal file
|
|
@ -0,0 +1,447 @@
|
||||||
|
"""Tests for ``erldist_clients`` — dist-transport wallet clients.
|
||||||
|
|
||||||
|
The Wallet.Service cross-stack tests in ~/git/wallet.unsandbox.com
|
||||||
|
already prove the wire contract works end-to-end against a real
|
||||||
|
Elixir node + mock daemons. These tests assert the MPS-side adapter:
|
||||||
|
|
||||||
|
- Each client builds the correct control tuple for Wallet.Service's
|
||||||
|
handle_call clauses
|
||||||
|
- ``{:ok, value}`` replies decode to Python dict/str/int matching the
|
||||||
|
HTTP client's contract (atoms → str, binaries → str, recursive)
|
||||||
|
- ``{:error, _}`` replies raise WalletDistError
|
||||||
|
- CallTimeout / NodeError from erldistpy surface as WalletDistError
|
||||||
|
|
||||||
|
We mock ``erldistpy.Node`` rather than running a real wallet node — that
|
||||||
|
end-to-end path is covered by the wallet.unsandbox.com cross-stack suite.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# Lazy imports inside the module under test; we still need the symbols
|
||||||
|
# for tests directly. Importing them at module load is OK because
|
||||||
|
# erldistpy *is* installed in the test venv (or skipped if not).
|
||||||
|
try:
|
||||||
|
from erldistpy import Atom, CallTimeout, NodeError
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
pytest.skip("erldistpy not installed", allow_module_level=True)
|
||||||
|
|
||||||
|
from make_post_sell.lib.crypto_watcher.erldist_clients import (
|
||||||
|
ErlangDistBitcoinClient,
|
||||||
|
ErlangDistDogecoinClient,
|
||||||
|
ErlangDistLitecoinClient,
|
||||||
|
ErlangDistMoneroClient,
|
||||||
|
WalletDistError,
|
||||||
|
refund_economically_viable,
|
||||||
|
refund_with_fee_split,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Test infrastructure — replace _DistConn.call so we don't open a real Node
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class FakeConn:
|
||||||
|
"""Minimal stand-in for ``_DistConn`` that records calls and returns
|
||||||
|
canned replies."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.calls = []
|
||||||
|
self._replies = []
|
||||||
|
|
||||||
|
def will_reply(self, *replies):
|
||||||
|
"""Queue one or more replies; consumed FIFO on each ``call``."""
|
||||||
|
self._replies.extend(replies)
|
||||||
|
|
||||||
|
def call(self, msg, timeout=None):
|
||||||
|
self.calls.append({"msg": msg, "timeout": timeout})
|
||||||
|
if not self._replies:
|
||||||
|
raise AssertionError(f"unexpected call (no reply queued): {msg!r}")
|
||||||
|
reply = self._replies.pop(0)
|
||||||
|
if isinstance(reply, Exception):
|
||||||
|
raise reply
|
||||||
|
return reply
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fake_conn():
|
||||||
|
return FakeConn()
|
||||||
|
|
||||||
|
|
||||||
|
def _attach_fake_conn(client, fake_conn):
|
||||||
|
"""Replace the freshly-constructed ``_DistConn`` on a client with our fake."""
|
||||||
|
client._conn = fake_conn
|
||||||
|
|
||||||
|
|
||||||
|
def _make_client(cls, **overrides):
|
||||||
|
"""Construct a dist client without opening a real Node connection."""
|
||||||
|
# Patch erldistpy.Node so __init__ doesn't try to dial cammy.
|
||||||
|
with patch("erldistpy.Node") as ctor:
|
||||||
|
ctor.return_value = MagicMock()
|
||||||
|
defaults = dict(
|
||||||
|
our_name="mps@local",
|
||||||
|
peer_name="wallet",
|
||||||
|
cookie="X",
|
||||||
|
)
|
||||||
|
defaults.update(overrides)
|
||||||
|
return cls(**defaults)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# ErlangDistMoneroClient
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestMoneroCreateSubaddress:
|
||||||
|
def test_returns_address_and_index_tuple(self, fake_conn):
|
||||||
|
fake_conn.will_reply((Atom("ok"), (b"4xmraddress", 7)))
|
||||||
|
client = _make_client(ErlangDistMoneroClient)
|
||||||
|
_attach_fake_conn(client, fake_conn)
|
||||||
|
|
||||||
|
addr, idx = client.create_subaddress(account_index=0, label="checkout-42")
|
||||||
|
|
||||||
|
assert addr == "4xmraddress"
|
||||||
|
assert idx == 7
|
||||||
|
sent_msg = fake_conn.calls[0]["msg"]
|
||||||
|
assert sent_msg[0] == Atom("monero")
|
||||||
|
assert sent_msg[1] == Atom("create_subaddress")
|
||||||
|
assert sent_msg[2] == [0, b"checkout-42"]
|
||||||
|
|
||||||
|
def test_none_label_passed_through(self, fake_conn):
|
||||||
|
fake_conn.will_reply((Atom("ok"), (b"4xmraddress", 1)))
|
||||||
|
client = _make_client(ErlangDistMoneroClient)
|
||||||
|
_attach_fake_conn(client, fake_conn)
|
||||||
|
|
||||||
|
client.create_subaddress(account_index=0)
|
||||||
|
|
||||||
|
sent_msg = fake_conn.calls[0]["msg"]
|
||||||
|
# None label preserved so Elixir side picks its default
|
||||||
|
assert sent_msg[2] == [0, None]
|
||||||
|
|
||||||
|
def test_error_reply_raises(self, fake_conn):
|
||||||
|
fake_conn.will_reply((Atom("error"), Atom("wallet_not_ready")))
|
||||||
|
client = _make_client(ErlangDistMoneroClient)
|
||||||
|
_attach_fake_conn(client, fake_conn)
|
||||||
|
|
||||||
|
with pytest.raises(WalletDistError, match="create_subaddress failed"):
|
||||||
|
client.create_subaddress()
|
||||||
|
|
||||||
|
|
||||||
|
class TestMoneroGetTransfers:
|
||||||
|
def test_returns_decoded_map(self, fake_conn):
|
||||||
|
fake_conn.will_reply(
|
||||||
|
(
|
||||||
|
Atom("ok"),
|
||||||
|
{
|
||||||
|
b"in": [
|
||||||
|
{b"amount": 1_000_000_000, b"txid": b"tx1", b"confirmations": 10}
|
||||||
|
],
|
||||||
|
b"pool": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
client = _make_client(ErlangDistMoneroClient)
|
||||||
|
_attach_fake_conn(client, fake_conn)
|
||||||
|
|
||||||
|
result = client.get_transfers_for_subaddr(0, [3, 4, 5])
|
||||||
|
|
||||||
|
assert result["in"][0]["txid"] == "tx1"
|
||||||
|
assert result["in"][0]["amount"] == 1_000_000_000
|
||||||
|
sent_msg = fake_conn.calls[0]["msg"]
|
||||||
|
assert sent_msg[2] == [0, [3, 4, 5]]
|
||||||
|
|
||||||
|
|
||||||
|
class TestMoneroGetHeight:
|
||||||
|
def test_returns_int(self, fake_conn):
|
||||||
|
fake_conn.will_reply((Atom("ok"), 3_456_789))
|
||||||
|
client = _make_client(ErlangDistMoneroClient)
|
||||||
|
_attach_fake_conn(client, fake_conn)
|
||||||
|
|
||||||
|
assert client.get_height() == 3_456_789
|
||||||
|
|
||||||
|
|
||||||
|
class TestMoneroIsSynced:
|
||||||
|
def test_synced_when_height_positive(self, fake_conn):
|
||||||
|
fake_conn.will_reply((Atom("ok"), 100))
|
||||||
|
client = _make_client(ErlangDistMoneroClient)
|
||||||
|
_attach_fake_conn(client, fake_conn)
|
||||||
|
assert client.is_synced() is True
|
||||||
|
|
||||||
|
def test_not_synced_on_error(self, fake_conn):
|
||||||
|
fake_conn.will_reply((Atom("error"), Atom("wallet_disconnected")))
|
||||||
|
client = _make_client(ErlangDistMoneroClient)
|
||||||
|
_attach_fake_conn(client, fake_conn)
|
||||||
|
# is_synced swallows errors and returns False (matches existing
|
||||||
|
# MoneroClient semantics)
|
||||||
|
assert client.is_synced() is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestMoneroSweepSubaddress:
|
||||||
|
def test_returns_tx_hash_string(self, fake_conn):
|
||||||
|
fake_conn.will_reply((Atom("ok"), b"sweep-tx-abc"))
|
||||||
|
client = _make_client(ErlangDistMoneroClient)
|
||||||
|
_attach_fake_conn(client, fake_conn)
|
||||||
|
|
||||||
|
tx = client.sweep_subaddress(0, 5, "cold-xmr", priority=1)
|
||||||
|
|
||||||
|
assert tx == "sweep-tx-abc"
|
||||||
|
assert fake_conn.calls[0]["timeout"] == 30.0
|
||||||
|
|
||||||
|
|
||||||
|
class TestMoneroSweepSubaddressWithDetails:
|
||||||
|
def test_returns_amount_and_tx_hash(self, fake_conn):
|
||||||
|
fake_conn.will_reply(
|
||||||
|
(Atom("ok"), {Atom("tx_hash"): b"tx-7", Atom("amount"): 12_345_678})
|
||||||
|
)
|
||||||
|
client = _make_client(ErlangDistMoneroClient)
|
||||||
|
_attach_fake_conn(client, fake_conn)
|
||||||
|
|
||||||
|
result = client.sweep_subaddress_with_details(0, 5, "cold-xmr", priority=1)
|
||||||
|
|
||||||
|
assert result == {"tx_hash": "tx-7", "amount": 12_345_678}
|
||||||
|
|
||||||
|
def test_no_balance_error_surfaces(self, fake_conn):
|
||||||
|
fake_conn.will_reply((Atom("error"), Atom("no_unlocked_balance")))
|
||||||
|
client = _make_client(ErlangDistMoneroClient)
|
||||||
|
_attach_fake_conn(client, fake_conn)
|
||||||
|
|
||||||
|
with pytest.raises(WalletDistError):
|
||||||
|
client.sweep_subaddress_with_details(0, 5, "cold-xmr")
|
||||||
|
|
||||||
|
|
||||||
|
class TestMoneroTransfer:
|
||||||
|
def test_encodes_destinations_as_binary_keyed_maps(self, fake_conn):
|
||||||
|
fake_conn.will_reply(
|
||||||
|
(Atom("ok"), {b"tx_hash": b"refund-tx", b"tx_key": b"key1"})
|
||||||
|
)
|
||||||
|
client = _make_client(ErlangDistMoneroClient)
|
||||||
|
_attach_fake_conn(client, fake_conn)
|
||||||
|
|
||||||
|
client.transfer(
|
||||||
|
destinations=[{"amount": 900, "address": "cust"}, {"amount": 100, "address": "shop"}],
|
||||||
|
account_index=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
sent_destinations = fake_conn.calls[0]["msg"][2][0]
|
||||||
|
assert sent_destinations == [
|
||||||
|
{b"amount": 900, b"address": b"cust"},
|
||||||
|
{b"amount": 100, b"address": b"shop"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TestMoneroGetTxConfirmations:
|
||||||
|
def test_returns_count(self, fake_conn):
|
||||||
|
fake_conn.will_reply((Atom("ok"), 13))
|
||||||
|
client = _make_client(ErlangDistMoneroClient)
|
||||||
|
_attach_fake_conn(client, fake_conn)
|
||||||
|
assert client.get_tx_confirmations("hash1", account_index=2) == 13
|
||||||
|
|
||||||
|
sent_msg = fake_conn.calls[0]["msg"]
|
||||||
|
assert sent_msg[2] == [b"hash1", 2]
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# UTXO clients (Bitcoin / Litecoin / Dogecoin)
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"client_cls,coin_atom",
|
||||||
|
[
|
||||||
|
(ErlangDistBitcoinClient, "btc"),
|
||||||
|
(ErlangDistLitecoinClient, "ltc"),
|
||||||
|
(ErlangDistDogecoinClient, "doge"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
class TestUtxoClients:
|
||||||
|
"""Each UTXO subclass shares behavior; parametrize once."""
|
||||||
|
|
||||||
|
def test_getnewaddress(self, client_cls, coin_atom, fake_conn):
|
||||||
|
fake_conn.will_reply((Atom("ok"), b"new-addr"))
|
||||||
|
client = _make_client(client_cls)
|
||||||
|
_attach_fake_conn(client, fake_conn)
|
||||||
|
|
||||||
|
addr = client.getnewaddress("shop-1:order-99")
|
||||||
|
|
||||||
|
assert addr == "new-addr"
|
||||||
|
msg = fake_conn.calls[0]["msg"]
|
||||||
|
assert msg[0] == Atom("utxo")
|
||||||
|
assert msg[1] == Atom(coin_atom)
|
||||||
|
assert msg[2] == Atom("getnewaddress")
|
||||||
|
assert msg[3] == [b"shop-1:order-99"]
|
||||||
|
|
||||||
|
def test_getreceivedbyaddress(self, client_cls, coin_atom, fake_conn):
|
||||||
|
fake_conn.will_reply((Atom("ok"), 1.234))
|
||||||
|
client = _make_client(client_cls)
|
||||||
|
_attach_fake_conn(client, fake_conn)
|
||||||
|
|
||||||
|
amt = client.getreceivedbyaddress("some-addr", minconf=3)
|
||||||
|
|
||||||
|
assert amt == 1.234
|
||||||
|
msg = fake_conn.calls[0]["msg"]
|
||||||
|
assert msg[3] == [b"some-addr", 3]
|
||||||
|
|
||||||
|
def test_gettransaction_decodes_map(self, client_cls, coin_atom, fake_conn):
|
||||||
|
fake_conn.will_reply(
|
||||||
|
(Atom("ok"), {b"confirmations": 6, b"amount": 0.5, b"txid": b"some-txid"})
|
||||||
|
)
|
||||||
|
client = _make_client(client_cls)
|
||||||
|
_attach_fake_conn(client, fake_conn)
|
||||||
|
|
||||||
|
tx = client.gettransaction("some-txid")
|
||||||
|
|
||||||
|
assert tx == {"confirmations": 6, "amount": 0.5, "txid": "some-txid"}
|
||||||
|
|
||||||
|
def test_get_tx_confirmations(self, client_cls, coin_atom, fake_conn):
|
||||||
|
fake_conn.will_reply((Atom("ok"), 7))
|
||||||
|
client = _make_client(client_cls)
|
||||||
|
_attach_fake_conn(client, fake_conn)
|
||||||
|
|
||||||
|
assert client.get_tx_confirmations("txid") == 7
|
||||||
|
|
||||||
|
def test_sendmany(self, client_cls, coin_atom, fake_conn):
|
||||||
|
fake_conn.will_reply((Atom("ok"), b"tx-broadcast"))
|
||||||
|
client = _make_client(client_cls)
|
||||||
|
_attach_fake_conn(client, fake_conn)
|
||||||
|
|
||||||
|
tx = client.sendmany(
|
||||||
|
from_label="",
|
||||||
|
addresses_amounts={"addr1": 1.5, "addr2": 2.5},
|
||||||
|
minconf=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert tx == "tx-broadcast"
|
||||||
|
msg = fake_conn.calls[0]["msg"]
|
||||||
|
# outputs map encoded as binary keys + float values
|
||||||
|
outputs = msg[3][0]
|
||||||
|
assert outputs[b"addr1"] == 1.5
|
||||||
|
assert outputs[b"addr2"] == 2.5
|
||||||
|
|
||||||
|
def test_is_synced_true_when_blocks_caught_up(
|
||||||
|
self, client_cls, coin_atom, fake_conn
|
||||||
|
):
|
||||||
|
fake_conn.will_reply(
|
||||||
|
(Atom("ok"), {b"blocks": 1_000_000, b"headers": 1_000_001})
|
||||||
|
)
|
||||||
|
client = _make_client(client_cls)
|
||||||
|
_attach_fake_conn(client, fake_conn)
|
||||||
|
|
||||||
|
assert client.is_synced() is True
|
||||||
|
|
||||||
|
def test_is_synced_false_when_error(self, client_cls, coin_atom, fake_conn):
|
||||||
|
fake_conn.will_reply((Atom("error"), Atom("rpc_down")))
|
||||||
|
client = _make_client(client_cls)
|
||||||
|
_attach_fake_conn(client, fake_conn)
|
||||||
|
|
||||||
|
assert client.is_synced() is False
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Cross-coin helpers
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestRefundWithFeeSplit:
|
||||||
|
def test_xmr_single_refund(self, fake_conn):
|
||||||
|
fake_conn.will_reply((Atom("ok"), {Atom("tx_hash"): b"refund-tx-1"}))
|
||||||
|
result = refund_with_fee_split(
|
||||||
|
fake_conn, "xmr", "cust-addr", 1_000_000_000_000
|
||||||
|
)
|
||||||
|
assert result == {"tx_hash": "refund-tx-1"}
|
||||||
|
|
||||||
|
msg = fake_conn.calls[0]["msg"]
|
||||||
|
assert msg[0] == Atom("refund_with_fee_split")
|
||||||
|
assert msg[1] == Atom("xmr")
|
||||||
|
assert msg[2] == b"cust-addr"
|
||||||
|
assert msg[3] == 1_000_000_000_000
|
||||||
|
assert msg[4] is None # no shop address
|
||||||
|
assert msg[5] == 0 # no shop amount
|
||||||
|
|
||||||
|
def test_doge_split_refund(self, fake_conn):
|
||||||
|
fake_conn.will_reply((Atom("ok"), {Atom("tx_hash"): b"split-tx"}))
|
||||||
|
result = refund_with_fee_split(
|
||||||
|
fake_conn,
|
||||||
|
"doge",
|
||||||
|
"cust-doge",
|
||||||
|
9_000_000_000,
|
||||||
|
shop_address="shop-doge",
|
||||||
|
shop_amount_atomic=1_000_000_000,
|
||||||
|
)
|
||||||
|
assert result == {"tx_hash": "split-tx"}
|
||||||
|
|
||||||
|
msg = fake_conn.calls[0]["msg"]
|
||||||
|
assert msg[4] == b"shop-doge"
|
||||||
|
assert msg[5] == 1_000_000_000
|
||||||
|
|
||||||
|
def test_unsupported_coin_propagates_error(self, fake_conn):
|
||||||
|
fake_conn.will_reply(
|
||||||
|
(Atom("error"), (Atom("unsupported_coin"), Atom("zec")))
|
||||||
|
)
|
||||||
|
with pytest.raises(WalletDistError):
|
||||||
|
refund_with_fee_split(fake_conn, "zec", "addr", 100)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRefundEconomicallyViable:
|
||||||
|
def test_nil_rate_always_viable(self):
|
||||||
|
assert refund_economically_viable(Decimal("10"), None)
|
||||||
|
|
||||||
|
def test_dust_refund_not_viable(self):
|
||||||
|
# 0.0001 XMR at $150 = $0.015 < $0.069
|
||||||
|
assert not refund_economically_viable(Decimal("0.0001"), Decimal("150"))
|
||||||
|
|
||||||
|
def test_meaningful_refund_viable(self):
|
||||||
|
# 1 DOGE at $0.10 = $0.10 > $0.069
|
||||||
|
assert refund_economically_viable(Decimal("1"), Decimal("0.10"))
|
||||||
|
|
||||||
|
def test_at_threshold_is_viable(self):
|
||||||
|
# exactly $0.069 -> >= comparison passes
|
||||||
|
assert refund_economically_viable(Decimal("1"), Decimal("0.069"))
|
||||||
|
|
||||||
|
def test_custom_min(self):
|
||||||
|
assert not refund_economically_viable(
|
||||||
|
Decimal("0.5"), Decimal("0.10"), min_usd=Decimal("0.10")
|
||||||
|
)
|
||||||
|
assert refund_economically_viable(
|
||||||
|
Decimal("0.5"), Decimal("0.10"), min_usd=Decimal("0.01")
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_accepts_non_decimal_numerics(self):
|
||||||
|
assert refund_economically_viable(100, 1)
|
||||||
|
assert refund_economically_viable("10", "0.5")
|
||||||
|
assert not refund_economically_viable(0.001, 0.1)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# Error mapping — CallTimeout / NodeError surface as WalletDistError
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestErrorMapping:
|
||||||
|
def test_call_timeout_becomes_wallet_dist_error(self, fake_conn):
|
||||||
|
fake_conn.will_reply(CallTimeout("dist timeout"))
|
||||||
|
client = _make_client(ErlangDistMoneroClient)
|
||||||
|
# We need the real _DistConn to translate exceptions — let it
|
||||||
|
# use the fake's call. Replace just the underlying _node.call.
|
||||||
|
real_conn = client._conn
|
||||||
|
real_conn._node = MagicMock()
|
||||||
|
real_conn._node.call.side_effect = CallTimeout("dist timeout")
|
||||||
|
|
||||||
|
with pytest.raises(WalletDistError, match="timed out"):
|
||||||
|
client.get_height()
|
||||||
|
|
||||||
|
def test_node_error_becomes_wallet_dist_error(self):
|
||||||
|
client = _make_client(ErlangDistMoneroClient)
|
||||||
|
real_conn = client._conn
|
||||||
|
real_conn._node = MagicMock()
|
||||||
|
real_conn._node.call.side_effect = NodeError("peer down")
|
||||||
|
|
||||||
|
with pytest.raises(WalletDistError, match="wallet RPC error"):
|
||||||
|
client.get_height()
|
||||||
Loading…
Add table
Add a link
Reference in a new issue