Add sync status checks for crypto payment methods

Crypto payment buttons now only appear when nodes are fully synced,
preventing failed payments due to incomplete blockchain synchronization.

- Add is_synced() and get_sync_status() methods to MoneroClient and DogecoinClient
- Add request.monero_synced and request.dogecoin_synced request methods
- Update payment visibility logic to require both enabled AND synced status
- Add helpful sync status messages in checkout template when nodes are syncing
- Prevent customer frustration from unsynchronized payment processing

Payment method visibility now requires:
1. App globally enabled
2. Shop has cold wallet configured
3. RPC accessible and responsive
4. Node/wallet fully synchronized
This commit is contained in:
Russell Ballestrini 2025-09-23 15:01:49 -04:00
parent 171fc916b7
commit fbc0b55dc3
4 changed files with 206 additions and 4 deletions

View file

@ -96,6 +96,55 @@ class MoneroClient:
res = self._call("get_height")
return int(res.get("height", 0))
def is_synced(self) -> bool:
"""
Check if the Monero wallet is fully synced.
Returns True if wallet height matches daemon height.
"""
try:
# Get wallet height
wallet_height = self.get_height()
# Get daemon height via wallet RPC
daemon_info = self._call("get_info")
daemon_height = int(daemon_info.get("height", 0))
# Consider synced if within 2 blocks (to handle timing issues)
return abs(wallet_height - daemon_height) <= 2
except Exception:
# If any RPC call fails, consider not synced
return False
def get_sync_status(self) -> dict:
"""
Get detailed sync status information.
Returns dict with wallet_height, daemon_height, synced status.
"""
try:
wallet_height = self.get_height()
daemon_info = self._call("get_info")
daemon_height = int(daemon_info.get("height", 0))
synced = abs(wallet_height - daemon_height) <= 2
sync_percentage = min(100.0, (wallet_height / max(daemon_height, 1)) * 100)
return {
"wallet_height": wallet_height,
"daemon_height": daemon_height,
"synced": synced,
"sync_percentage": sync_percentage,
"blocks_behind": max(0, daemon_height - wallet_height)
}
except Exception as e:
return {
"wallet_height": 0,
"daemon_height": 0,
"synced": False,
"sync_percentage": 0.0,
"blocks_behind": 0,
"error": str(e)
}
class MockMoneroClient:
"""
@ -147,6 +196,21 @@ class MockMoneroClient:
def get_height(self) -> int:
return int(self._data.get("height", 0))
def is_synced(self) -> bool:
"""Mock client is always considered synced for testing."""
return True
def get_sync_status(self) -> dict:
"""Mock client returns synced status for testing."""
height = self.get_height()
return {
"wallet_height": height,
"daemon_height": height,
"synced": True,
"sync_percentage": 100.0,
"blocks_behind": 0
}
class DogecoinClient:
"""
@ -259,6 +323,63 @@ class DogecoinClient:
"""Get network status information."""
return self._call("getnetworkinfo")
def getblockchaininfo(self) -> Dict[str, Any]:
"""Get blockchain synchronization status."""
return self._call("getblockchaininfo")
def is_synced(self) -> bool:
"""
Check if the Dogecoin node is fully synced.
Returns True if verification progress is near 100%.
"""
try:
info = self.getblockchaininfo()
progress = float(info.get("verificationprogress", 0))
# Consider synced if >99.9% to handle small timing issues
return progress >= 0.999
except Exception:
# If RPC call fails, consider not synced
return False
def get_sync_status(self) -> dict:
"""
Get detailed sync status information.
Returns dict with blocks, headers, sync progress, etc.
"""
try:
blockchain_info = self.getblockchaininfo()
blocks = int(blockchain_info.get("blocks", 0))
headers = int(blockchain_info.get("headers", 0))
progress = float(blockchain_info.get("verificationprogress", 0))
synced = progress >= 0.999
sync_percentage = progress * 100
blocks_behind = max(0, headers - blocks)
return {
"blocks": blocks,
"headers": headers,
"synced": synced,
"sync_percentage": sync_percentage,
"blocks_behind": blocks_behind,
"verification_progress": progress,
"pruned": blockchain_info.get("pruned", False),
"size_on_disk": blockchain_info.get("size_on_disk", 0)
}
except Exception as e:
return {
"blocks": 0,
"headers": 0,
"synced": False,
"sync_percentage": 0.0,
"blocks_behind": 0,
"verification_progress": 0.0,
"pruned": False,
"size_on_disk": 0,
"error": str(e)
}
class MockDogecoinClient:
"""Mock client for testing without a real Dogecoin node."""
@ -331,6 +452,34 @@ class MockDogecoinClient:
"connections": 8,
}
def getblockchaininfo(self) -> Dict[str, Any]:
"""Mock blockchain info - always synced for testing."""
return {
"chain": "main",
"blocks": 5500000,
"headers": 5500000,
"verificationprogress": 1.0,
"pruned": False,
"size_on_disk": 2000000000 # 2GB
}
def is_synced(self) -> bool:
"""Mock client is always considered synced for testing."""
return True
def get_sync_status(self) -> dict:
"""Mock client returns synced status for testing."""
return {
"blocks": 5500000,
"headers": 5500000,
"synced": True,
"sync_percentage": 100.0,
"blocks_behind": 0,
"verification_progress": 1.0,
"pruned": False,
"size_on_disk": 2000000000
}
def get_client_from_settings(settings) -> MoneroClient:
"""

View file

@ -207,6 +207,20 @@ def includeme(config):
except Exception:
return False
def add_monero_synced(request):
"""Check if Monero wallet is available, responding, AND fully synced."""
if not request.monero_enabled:
return False
try:
from ..lib.crypto_clients import get_client_from_settings
client = get_client_from_settings(request.registry.settings)
# Check if wallet is synced (ready for payment processing)
return client.is_synced()
except Exception:
return False
def add_dogecoin_enabled(request):
"""Check if Dogecoin payments are enabled globally."""
try:
@ -234,6 +248,20 @@ def includeme(config):
except Exception:
return False
def add_dogecoin_synced(request):
"""Check if Dogecoin node is available, responding, AND fully synced."""
if not request.dogecoin_enabled:
return False
try:
from ..lib.crypto_clients import get_dogecoin_client_from_settings
client = get_dogecoin_client_from_settings(request.registry.settings)
# Check if node is synced (ready for payment processing)
return client.is_synced()
except Exception:
return False
# Register functions to app config as request methods.
# To prevent multiple DB lookups, cache result with `reify=True`.
config.add_request_method(add_debug_mode, "debug_mode", reify=True)
@ -269,10 +297,12 @@ def includeme(config):
config.add_request_method(
add_monero_rpc_available, "monero_rpc_available", reify=True
)
config.add_request_method(add_monero_synced, "monero_synced", reify=True)
config.add_request_method(add_dogecoin_enabled, "dogecoin_enabled", reify=True)
config.add_request_method(
add_dogecoin_rpc_available, "dogecoin_rpc_available", reify=True
)
config.add_request_method(add_dogecoin_synced, "dogecoin_synced", reify=True)
def add_has_xmr_refund_address(request):
"""Check if the current user has an XMR refund address configured."""

View file

@ -123,6 +123,27 @@
{% endif %}
{% endif %}
{# Show sync status messages for enabled but not synced crypto methods #}
{% if monero_enabled and not monero_synced and cart.requires_payment and cart.shop_product_dict|length == 1 %}
<br/>
<div style="background:#fff3cd; border:1px solid #ffeaa7; padding:10px; border-radius:5px; color:#856404;">
<p style="margin:0; font-size:14px;">
⏳ <strong>Monero (XMR) payments temporarily unavailable</strong><br/>
The Monero wallet is still synchronizing with the blockchain. Please try again later.
</p>
</div>
{% endif %}
{% if dogecoin_enabled and not dogecoin_synced and cart.requires_payment and cart.shop_product_dict|length == 1 %}
<br/>
<div style="background:#fff3cd; border:1px solid #ffeaa7; padding:10px; border-radius:5px; color:#856404;">
<p style="margin:0; font-size:14px;">
⏳ <strong>Dogecoin (DOGE) payments temporarily unavailable</strong><br/>
The Dogecoin node is still synchronizing with the blockchain. Please try again later.
</p>
</div>
{% endif %}
{% if not stripe_enabled and not (monero_enabled and xmr_processor_enabled) and not (dogecoin_enabled and doge_processor_enabled) %}
<br/>
<p>No payment methods are enabled. Please contact the shop owner.</p>

View file

@ -472,9 +472,9 @@ def cart_checkout(request):
request.session.flash(msg)
return HTTPFound("/billing")
# Check if shop has enabled XMR crypto processor
# Check if shop has enabled XMR crypto processor AND wallet is synced
xmr_processor_enabled = False
if request.monero_enabled:
if request.monero_enabled and request.monero_synced:
from ..models.crypto_processor import CryptoProcessor
xmr_processor = (
@ -488,9 +488,9 @@ def cart_checkout(request):
)
xmr_processor_enabled = xmr_processor is not None
# Check if shop has enabled DOGE crypto processor
# Check if shop has enabled DOGE crypto processor AND node is synced
doge_processor_enabled = False
if request.dogecoin_enabled:
if request.dogecoin_enabled and request.dogecoin_synced:
from ..models.crypto_processor import CryptoProcessor
doge_processor = (
@ -530,8 +530,10 @@ def cart_checkout(request):
"active_card": stripe_user_shop.active_card if stripe_user_shop else None,
"stripe_enabled": request.stripe_enabled,
"monero_enabled": request.monero_enabled,
"monero_synced": request.monero_synced,
"xmr_processor_enabled": xmr_processor_enabled,
"dogecoin_enabled": request.dogecoin_enabled,
"dogecoin_synced": request.dogecoin_synced,
"doge_processor_enabled": doge_processor_enabled,
"pending_crypto_quotes": pending_quotes,
}