modified: .gitignore

modified:   make_post_sell/lib/crypto_watcher.py
	modified:   make_post_sell/models/crypto_payment.py
	modified:   make_post_sell/models/crypto_processor.py
	modified:   make_post_sell/models/user_crypto_refund_address.py
	modified:   make_post_sell/request_methods.py
	deleted:    make_post_sell/scripts/alembic/versions/5997b993482e_add_indexes_for_crypto_payment_address_.py
	deleted:    make_post_sell/scripts/alembic/versions/5bbb5df5bf1b_add_refund_tracking_columns_to_crypto_.py
	deleted:    make_post_sell/scripts/alembic/versions/dd7466bfc690_make_crypto_payment_invoice_id_nullable_.py
	modified:   make_post_sell/templates/actions_new.j2
	modified:   make_post_sell/tests/test_crypto_watcher.py
	modified:   make_post_sell/views/crypto.py
	modified:   make_post_sell/views/user_crypto_settings.py
This commit is contained in:
Russell Ballestrini 2025-09-26 06:53:07 -04:00
parent f1b411ecb8
commit f1545f3b82
13 changed files with 394 additions and 245 deletions

1
.gitignore vendored
View file

@ -33,4 +33,5 @@ caddy
monero-wallet-cli.log
monero-wallet-rpc.log
monero-wallet-rpc.log*

View file

@ -19,6 +19,115 @@ from ..models.meta import now_timestamp
logger = logging.getLogger(__name__)
def _get_scan_position_from_semaphore(semaphore, coin_type):
"""Extract scan position from semaphore string.
Args:
semaphore: String in format "type:value" or None
coin_type: Coin type for context
Returns:
int: Scan position (height for XMR, 0 if no semaphore)
"""
if not semaphore:
return 0
if ":" not in semaphore:
return 0
sem_type, value = semaphore.split(":", 1)
if coin_type == "XMR" and sem_type == "height":
try:
return int(value)
except (ValueError, TypeError):
return 0
elif coin_type in ("DOGE", "BTC", "LTC", "BCH") and sem_type == "blockhash":
# For blockhash semaphores, we can't directly compare
# Return 1 to indicate we have a valid starting point
return 1 if value else 0
return 0
def _format_scan_semaphore(coin_type, position_value):
"""Format scan position into semaphore string.
Args:
coin_type: Coin type
position_value: Position value (height for XMR, blockhash for DOGE/BTC)
Returns:
str: Formatted semaphore string
"""
if coin_type == "XMR":
return f"height:{position_value}"
elif coin_type in ("DOGE", "BTC", "LTC", "BCH"):
return f"blockhash:{position_value}"
return None
def _should_process_late_payment(payment, tx):
"""Check if a payment should be processed as a late/edge case payment.
Handles all edge cases:
1. Late payment to expired quote
2. Late payment to cancelled quote
3. Double payment to completed order
4. Overpayment
5. Multiple payments to same quote
6. Underpayment top-up
Args:
payment: CryptoPayment object
tx: Transaction data
Returns:
bool: True if this payment should be processed
"""
if not payment:
return False
# Get transaction amount (normalize for coin type)
if payment.coin_type == "XMR":
tx_amount = int(tx.get("amount", 0))
else:
# DOGE/BTC amount is already in atomic units
tx_amount = int(float(tx.get("amount", 0)) * 1e8)
# Edge Case 1 & 2: Late payments to expired/cancelled quotes
if payment.status in [CryptoPayment.STATUS_EXPIRED, CryptoPayment.STATUS_CANCELLED]:
return True
# Edge Case 3: Double payment - payment already completed but more funds arrived
if payment.status == CryptoPayment.STATUS_PAID and tx_amount > 0:
logger.info(f"Double payment detected for completed payment {payment.id}")
return True
# Edge Case 4 & 5: Overpayment or multiple payments (payment is confirmed but getting more)
if payment.status == CryptoPayment.STATUS_CONFIRMED and payment.received_amount > 0:
current_total = payment.received_amount + tx_amount
if current_total > payment.expected_amount:
logger.info(f"Overpayment detected for payment {payment.id}: expected {payment.expected_amount}, will receive {current_total}")
return True
# Edge Case 6: Underpayment top-up (partially paid, now getting more)
if (payment.status == CryptoPayment.STATUS_UNDERPAID or
(payment.received_amount > 0 and payment.received_amount < payment.expected_amount)):
logger.info(f"Underpayment top-up detected for payment {payment.id}")
return True
# Edge Case: First payment to a fresh quote (normal processing should handle this, but just in case)
if payment.received_amount == 0 and payment.status in [
CryptoPayment.STATUS_PENDING,
CryptoPayment.STATUS_CONFIRMED
]:
return True
return False
# Constants
COIN_CONFIGS = {
"XMR": {
@ -1525,47 +1634,93 @@ def update_payment_confirmations_only(client, crypto_payment, coin_type):
def scan_wallet_for_late_payments(request, settings):
"""
Scan wallet for ALL incoming transactions and match them to payments.
Scan wallet for new incoming transactions since last scan position and match them to payments.
This catches late payments to expired quotes that aren't actively monitored.
Uses scan position tracking to avoid rescanning old transfers.
"""
logger.info("Starting wallet scan for late payments")
db = request.dbsession
# Import here to avoid circular imports
from ..models.crypto_processor import CryptoProcessor
# Get all active crypto processors (one per shop/coin combination)
processors = db.query(CryptoProcessor).filter(CryptoProcessor.enabled == True).all()
if not processors:
logger.debug("No active crypto processors found for scanning")
return
logger.info(f"Found {len(processors)} active crypto processors to scan")
# Group processors by coin type for efficient scanning
processors_by_coin = {}
for processor in processors:
coin_type = processor.coin_type
if coin_type not in processors_by_coin:
processors_by_coin[coin_type] = []
processors_by_coin[coin_type].append(processor)
# Process each coin type
for coin_type in ["XMR", "DOGE"]:
for coin_type, coin_processors in processors_by_coin.items():
try:
client = get_crypto_client(settings, coin_type)
except ValueError as e:
logger.debug(f"Skipping {coin_type} wallet scan: {e}")
continue
logger.info(f"Scanning {coin_type} wallet for incoming transactions")
logger.info(
f"Scanning {coin_type} wallet for {len(coin_processors)} processors"
)
scan_start_time = time.time()
if coin_type == "XMR":
# Get ALL incoming transfers from the wallet
try:
result = client._call(
"get_transfers",
{
"in": True,
"out": False,
"pending": True,
"failed": False,
"pool": True,
},
# Get the minimum scan position across all processors to determine what's "new"
min_scan_position = min(
_get_scan_position_from_semaphore(p.last_scan_semaphore, coin_type)
for p in coin_processors
)
# Build bounded query for XMR transfers
query_params = {
"in": True,
"out": False,
"pending": True,
"failed": False,
"pool": True,
}
# Add height bounds if we have a valid scan position
if min_scan_position > 0:
query_params["filter_by_height"] = True
query_params["min_height"] = min_scan_position
# Get transfers from the wallet using bounded query
result = client._call("get_transfers", query_params)
all_transfers = []
for transfer_type in ["in", "pending", "pool"]:
if transfer_type in result:
all_transfers.extend(result[transfer_type])
if not all_transfers:
logger.debug(f"No {coin_type} transfers found")
continue
# Process all transfers returned by bounded query
# RPC already filtered by height, so all transfers are "new"
new_transfers = all_transfers
max_height = max((tx.get("height", 0) for tx in all_transfers), default=0)
logger.info(
f"Found {len(all_transfers)} total incoming {coin_type} transfers"
f"Found {len(new_transfers)} incoming {coin_type} transfers newer than scan position {min_scan_position}"
)
# For each transfer, look up the payment by subaddress
for tx in all_transfers:
# Process new transfers and match to payments
late_payments_found = 0
for tx in new_transfers:
subaddr = tx.get("subaddr_index", {})
if not subaddr:
continue
@ -1587,28 +1742,120 @@ def scan_wallet_for_late_payments(request, settings):
.first()
)
if payment and payment.status in [
CryptoPayment.STATUS_EXPIRED,
CryptoPayment.STATUS_CANCELLED,
]:
if payment and _should_process_late_payment(payment, tx):
late_payments_found += 1
logger.info(
f"Found late payment to {payment.status} quote {payment.id}: "
f"{tx.get('amount', 0) / 1e12:.12f} XMR, "
f"{tx.get('confirmations', 0)} confirmations"
f"{tx.get('confirmations', 0)} confirmations, height {tx.get('height', 0)}"
)
# Process this late payment
incoming = [tx] # Process just this transfer
process_payment(request, payment, incoming, client=client)
# Update scan positions for all processors of this coin type
if max_height > 0: # Only update if we found confirmed transfers
for processor in coin_processors:
old_position = _get_scan_position_from_semaphore(processor.last_scan_semaphore, coin_type)
if old_position < max_height:
new_semaphore = _format_scan_semaphore(coin_type, max_height)
processor.last_scan_semaphore = new_semaphore
db.add(processor)
logger.debug(
f"Updated scan position for {processor.coin_type} processor {processor.id}: "
f"{old_position}{max_height}"
)
logger.info(
f"Wallet scan: ~{(time.time() - scan_start_time):.1f} seconds to scan {len(new_transfers)} transfers, "
f"found {late_payments_found} late payments"
)
except Exception as e:
logger.error(f"Failed to scan {coin_type} wallet: {e}")
elif coin_type == "DOGE":
# For DOGE, we need to get all addresses that have received funds
# This is more complex as we'd need to track all addresses we've generated
# For now, we'll skip DOGE passive scanning
logger.debug("DOGE passive scanning not yet implemented")
continue
try:
# Get the minimum scan semaphore across all processors
min_scan_semaphore = None
for p in coin_processors:
if p.last_scan_semaphore and p.last_scan_semaphore.startswith("blockhash:"):
min_scan_semaphore = p.last_scan_semaphore.split(":", 1)[1]
break
# Build bounded query for DOGE transfers using listsinceblock
if min_scan_semaphore:
# Get transactions since the last block hash
result = client.listsinceblock(min_scan_semaphore)
else:
# First scan - get recent transactions (last 100 blocks worth)
current_block_count = client.getblockcount()
recent_block_hash = client.getblockhash(max(0, current_block_count - 100))
result = client.listsinceblock(recent_block_hash)
all_transactions = result.get("transactions", [])
latest_block_hash = result.get("lastblock")
if not all_transactions:
logger.debug(f"No {coin_type} transactions found")
# Still update semaphore even if no transactions
if latest_block_hash:
for processor in coin_processors:
processor.last_scan_semaphore = f"blockhash:{latest_block_hash}"
db.add(processor)
continue
# Filter for incoming transactions only
incoming_transactions = [
tx for tx in all_transactions
if tx.get("category") == "receive" and tx.get("confirmations", 0) >= 0
]
logger.info(
f"Found {len(incoming_transactions)} incoming {coin_type} transactions newer than semaphore {min_scan_semaphore or 'genesis'}"
)
# Process incoming transactions and match to payments
late_payments_found = 0
for tx in incoming_transactions:
# Look up payment by address
payment = (
db.query(CryptoPayment)
.filter(
CryptoPayment.coin_type == coin_type,
CryptoPayment.address == tx.get("address"),
)
.first()
)
if payment and _should_process_late_payment(payment, tx):
late_payments_found += 1
logger.info(
f"Found late payment to {payment.status} quote {payment.id}: "
f"{tx.get('amount', 0)} {coin_type}, "
f"{tx.get('confirmations', 0)} confirmations"
)
# Process this late payment
process_payment(request, payment, [tx], client=client)
# Update scan semaphore for all processors
if latest_block_hash:
for processor in coin_processors:
processor.last_scan_semaphore = f"blockhash:{latest_block_hash}"
db.add(processor)
logger.debug(
f"Updated scan semaphore for {processor.coin_type} processor {processor.id}: "
f"{min_scan_semaphore or 'genesis'}{latest_block_hash[:16]}..."
)
logger.info(
f"Wallet scan: ~{(time.time() - scan_start_time):.1f} seconds to scan {len(incoming_transactions)} transactions, "
f"found {late_payments_found} late payments"
)
except Exception as e:
logger.error(f"Failed to scan {coin_type} wallet: {e}")
continue
def run_once(env, interval):

View file

@ -102,6 +102,11 @@ class CryptoPayment(RBase, Base):
BigInteger, nullable=True
) # Customer's tx fee (when available from RPC)
# Fee amount calculated at payment creation (to avoid expensive RPC calls on page loads)
estimated_fee_amount = Column(
BigInteger, nullable=True
) # Estimated fee in atomic units (piconero, satoshis, etc.)
# locked rate at the time of creating the payment (USD per coin)
rate_locked_usd_per_coin = Column(Numeric(18, 8), nullable=False)
@ -173,6 +178,7 @@ class CryptoPayment(RBase, Base):
shop_location=None,
shop_sweep_to_address=None,
refund_address=None,
estimated_fee_amount=None,
):
self.id = uuid.uuid1()
self.invoice = invoice
@ -184,6 +190,7 @@ class CryptoPayment(RBase, Base):
self.coin_type = coin_type
self.expected_amount = int(expected_amount)
self.received_amount = 0
self.estimated_fee_amount = estimated_fee_amount
self.rate_locked_usd_per_coin = rate_locked_usd_per_coin
self.quote_expires_at = int(quote_expires_at_ms)
self.confirmations_required = confirmations_required

View file

@ -23,6 +23,12 @@ class CryptoProcessor(RBase, Base):
# Wallet identifier - account index for Monero, label for Bitcoin-like
wallet_label = Column(Unicode(128), nullable=False)
# Generic semaphore for bounded scanning across different coin types
# Format: "type:value" where type is height|blockhash|timestamp
# XMR: "height:3507980"
# DOGE/BTC/LTC/BCH: "blockhash:00000000000001a2b3c4d5e6f7..."
last_scan_semaphore = Column(Unicode(255), nullable=True)
# Timestamps
created_timestamp = Column(BigInteger, nullable=False)
updated_timestamp = Column(BigInteger, nullable=False)

View file

@ -7,12 +7,13 @@ from .meta import Base, RBase, UUIDType, foreign_key, now_timestamp
class UserCryptoRefundAddress(RBase, Base):
"""
Stores cryptocurrency refund addresses for users.
One user can have one address per coin type.
Stores cryptocurrency refund addresses for users per shop.
One user can have one address per shop per coin type.
"""
id = Column(UUIDType, primary_key=True, index=True)
user_id = Column(UUIDType, foreign_key("User", "id"), nullable=False)
shop_id = Column(UUIDType, foreign_key("Shop", "id"), nullable=False)
# Coin type (e.g., 'XMR', 'BTC', 'LTC', 'DOGE', 'BCH')
coin_type = Column(Unicode(32), nullable=False)
@ -28,10 +29,12 @@ class UserCryptoRefundAddress(RBase, Base):
# Relationships
user = relationship("User", backref="crypto_refund_addresses")
shop = relationship("Shop", backref="user_crypto_refund_addresses")
def __init__(self, user, coin_type, address, label=None):
def __init__(self, user, shop, coin_type, address, label=None):
self.id = uuid.uuid1()
self.user = user
self.shop = shop
self.coin_type = coin_type.upper()
self.address = address
self.label = label
@ -40,20 +43,22 @@ class UserCryptoRefundAddress(RBase, Base):
self.updated_timestamp = now
# Create unique constraint for user_id + coin_type
# Create unique constraint for user_id + shop_id + coin_type
UniqueConstraint(
UserCryptoRefundAddress.user_id,
UserCryptoRefundAddress.shop_id,
UserCryptoRefundAddress.coin_type,
name="uq_user_crypto_refund_address_user_coin",
name="uq_user_crypto_refund_address_user_shop_coin",
)
def get_user_crypto_refund_address(dbsession, user, coin_type):
"""Get the refund address for a user and coin type."""
def get_user_crypto_refund_address(dbsession, user, shop, coin_type):
"""Get the refund address for a user, shop, and coin type."""
return (
dbsession.query(UserCryptoRefundAddress)
.filter(
UserCryptoRefundAddress.user_id == user.id,
UserCryptoRefundAddress.shop_id == shop.id,
UserCryptoRefundAddress.coin_type == coin_type.upper(),
)
.first()

View file

@ -45,20 +45,25 @@ def includeme(config):
def add_shop(request):
shop_id = request.params.get("shop_id", request.matchdict.get("shop_id"))
# print(f"add_shop: shop_id={shop_id}, params={request.params}, matchdict={request.matchdict}")
shop = None
if shop_id:
shop = get_shop_by_id(request.dbsession, shop_id)
# print(f"get_shop_by_id returned: {shop}")
elif request.product:
shop = request.product.shop
else:
if request.is_saas_domain:
if request.user and request.user.active_shop:
shop = request.user.active_shop
if request.user:
# First try to get the user's active shop
if request.user.active_shop:
shop = request.user.active_shop
elif request.user.shops:
# If user has shops but no active shop, set the first one as active
shop = request.user.shops[0]
request.user.set_active_shop(shop)
request.dbsession.add(request.user)
request.dbsession.flush()
else:
shop = get_shop_by_domain_name(request.dbsession, request.domain)
# print(f"add_shop returning: {shop}")
return shop
def add_active_cart(request):
@ -149,10 +154,10 @@ def includeme(config):
return False
def add_saas_domain(request):
return request.app.get("root_domain")
return request.app.get("make_post_sell.root_domain")
def add_saas_url(request):
return request.app.get("root_url")
return request.app.get("make_post_sell.root_url")
def add_is_saas_domain(request):
"""
@ -163,9 +168,16 @@ def includeme(config):
* show two different "home" pages
* show only certain buttons on the SaaS domain.
"""
root_domain = request.app.get("root_domain")
root_domain = request.app.get("make_post_sell.root_domain")
# For development, always treat localhost as SaaS domain for convenience
if request.domain == "localhost":
return True
# Standard SaaS domain check
if root_domain:
return request.domain.endswith(root_domain)
return False
def add_stripe_enabled(request):
@ -310,23 +322,23 @@ def includeme(config):
def add_has_xmr_refund_address(request):
"""Check if the current user has an XMR refund address configured."""
if not request.user:
if not request.user or not request.shop:
return False
from .models.user_crypto_refund_address import get_user_crypto_refund_address
refund_address = get_user_crypto_refund_address(
request.dbsession, request.user, "XMR"
request.dbsession, request.user, request.shop, "XMR"
)
return refund_address is not None and refund_address.address is not None
def add_has_doge_refund_address(request):
"""Check if the current user has a DOGE refund address configured."""
if not request.user:
if not request.user or not request.shop:
return False
from .models.user_crypto_refund_address import get_user_crypto_refund_address
refund_address = get_user_crypto_refund_address(
request.dbsession, request.user, "DOGE"
request.dbsession, request.user, request.shop, "DOGE"
)
return refund_address is not None and refund_address.address is not None

View file

@ -1,36 +0,0 @@
"""Add indexes for crypto payment address lookups
Revision ID: 5997b993482e
Revises: dd7466bfc690
Create Date: 2025-09-25 13:15:50.163828
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "5997b993482e"
down_revision = "dd7466bfc690"
branch_labels = None
depends_on = None
from make_post_sell.models.meta import UUIDType
def upgrade():
# Add index for DOGE/BTC/LTC address lookups
op.create_index("ix_mps_crypto_payment_address", "mps_crypto_payment", ["address"])
# Add composite index for XMR subaddress lookups
op.create_index(
"ix_mps_crypto_payment_subaddress",
"mps_crypto_payment",
["coin_type", "account_index", "subaddress_index"],
)
def downgrade():
op.drop_index("ix_mps_crypto_payment_subaddress", "mps_crypto_payment")
op.drop_index("ix_mps_crypto_payment_address", "mps_crypto_payment")

View file

@ -1,41 +0,0 @@
"""Add refund tracking columns to crypto_payment
Revision ID: 5bbb5df5bf1b
Revises: 0f59018f6537
Create Date: 2025-09-24 09:51:53.586990
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "5bbb5df5bf1b"
down_revision = "0f59018f6537"
branch_labels = None
depends_on = None
from make_post_sell.models.meta import UUIDType
def upgrade():
# Add refund tracking columns to crypto_payment table
op.add_column(
"mps_crypto_payment",
sa.Column("refund_reason", sa.UnicodeText(), nullable=True),
)
op.add_column(
"mps_crypto_payment",
sa.Column("refund_tx_hash", sa.Unicode(128), nullable=True),
)
op.add_column(
"mps_crypto_payment", sa.Column("refund_amount", sa.BigInteger(), nullable=True)
)
def downgrade():
# Remove refund tracking columns from crypto_payment table
op.drop_column("mps_crypto_payment", "refund_amount")
op.drop_column("mps_crypto_payment", "refund_tx_hash")
op.drop_column("mps_crypto_payment", "refund_reason")

View file

@ -1,71 +0,0 @@
"""Make crypto_payment.invoice_id nullable for cancelled payments
Revision ID: dd7466bfc690
Revises: 5bbb5df5bf1b
Create Date: 2025-09-24 18:44:52.643251
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "dd7466bfc690"
down_revision = "5bbb5df5bf1b"
branch_labels = None
depends_on = None
from make_post_sell.models.meta import UUIDType
def upgrade():
# Drop and recreate crypto_payment table with nullable invoice_id
# Safe to drop since no real customer data exists yet
op.drop_table("mps_crypto_payment")
# Recreate table with complete schema and nullable invoice_id
op.create_table(
"mps_crypto_payment",
sa.Column("id", UUIDType, primary_key=True, index=True),
sa.Column(
"invoice_id", UUIDType, sa.ForeignKey("mps_invoice.id"), nullable=True
),
sa.Column(
"shop_location_id",
UUIDType,
sa.ForeignKey("mps_shop_location.id"),
nullable=True,
),
sa.Column("address", sa.String(128), nullable=False),
sa.Column("account_index", sa.Integer, nullable=False),
sa.Column("subaddress_index", sa.Integer, nullable=False),
sa.Column("coin_type", sa.String(10), nullable=False),
sa.Column("expected_amount", sa.BigInteger, nullable=False),
sa.Column("received_amount", sa.BigInteger, nullable=False, default=0),
sa.Column("received_network_fee", sa.BigInteger, nullable=True),
sa.Column("rate_locked_usd_per_coin", sa.Numeric(18, 8), nullable=False),
sa.Column("quote_expires_at", sa.BigInteger, nullable=False),
sa.Column("confirmations_required", sa.Integer, nullable=False),
sa.Column("status", sa.String(32), nullable=False, default="pending"),
sa.Column("tx_hashes", sa.UnicodeText, nullable=False),
sa.Column("shop_sweep_to_address", sa.String(256), nullable=True),
sa.Column("refund_address", sa.String(256), nullable=True),
sa.Column("swept_amount", sa.BigInteger, nullable=True),
sa.Column("swept_tx_hash", sa.String(128), nullable=True),
sa.Column("swept_timestamp", sa.BigInteger, nullable=True),
sa.Column("swept_network_fee", sa.BigInteger, nullable=True),
sa.Column("current_confirmations", sa.Integer, nullable=False, default=0),
sa.Column("created_timestamp", sa.BigInteger, nullable=False),
sa.Column("updated_timestamp", sa.BigInteger, nullable=False),
sa.Column("refund_reason", sa.UnicodeText, nullable=True),
sa.Column("refund_tx_hash", sa.String(128), nullable=True),
sa.Column("refund_amount", sa.BigInteger, nullable=True),
)
def downgrade():
# Revert back to NOT NULL (but this could fail if there are NULL values)
op.alter_column(
"mps_crypto_payment", "invoice_id", existing_type=UUIDType, nullable=False
)

View file

@ -34,6 +34,16 @@
<a href="/s/new" class="shop-new-button mps-button mps-button">&#65291; New Shop</a>
{% endif %}
{% else %}
{% if request.is_saas_domain %}
<p>Create your first shop to get started!</p>
<br/>
<a href="/s/new" class="shop-new-button mps-button">&#65291; Create Your First Shop</a>
{% else %}
<p>No shop configured for this domain.</p>
{% endif %}
{% endif %}
</section>

View file

@ -1122,7 +1122,7 @@ class PassiveMonitoringTests(unittest.TestCase):
# Verify payment was looked up by subaddress
self.mock_dbsession.query.assert_called_with(CryptoPayment)
# Verify process_payment was called for expired payment
mock_process.assert_called_once()
call_args = mock_process.call_args
@ -1133,7 +1133,9 @@ class PassiveMonitoringTests(unittest.TestCase):
@patch("make_post_sell.lib.crypto_watcher.get_crypto_client")
@patch("make_post_sell.lib.crypto_watcher.process_payment")
def test_scan_wallet_skips_non_expired_payments(self, mock_process, mock_get_client):
def test_scan_wallet_skips_non_expired_payments(
self, mock_process, mock_get_client
):
"""Test scanning wallet ignores non-expired payments."""
# Mock XMR client with transfers
mock_client = MagicMock()
@ -1168,7 +1170,9 @@ class PassiveMonitoringTests(unittest.TestCase):
@patch("make_post_sell.lib.crypto_watcher.get_crypto_client")
@patch("make_post_sell.lib.crypto_watcher.process_payment")
def test_scan_wallet_handles_cancelled_payments(self, mock_process, mock_get_client):
def test_scan_wallet_handles_cancelled_payments(
self, mock_process, mock_get_client
):
"""Test scanning wallet processes cancelled payments with transfers."""
# Mock XMR client with transfers
mock_client = MagicMock()
@ -1231,11 +1235,11 @@ class PassiveMonitoringTests(unittest.TestCase):
class PassiveMonitoringIntegrationTests(unittest.TestCase):
"""Integration tests for passive wallet monitoring with real DB."""
def setUp(self):
settings = get_appsettings("data/test.ini")
from ..models import setup_db_with_settings
self.engine = setup_db_with_settings(settings)
self.session_factory = get_session_factory(self.engine)
@ -1275,12 +1279,13 @@ class PassiveMonitoringIntegrationTests(unittest.TestCase):
coin_type="XMR",
expected_amount=100000000000, # 0.1 XMR
rate_locked_usd_per_coin=150.0,
quote_expires_at_ms=int(time.time() * 1000) - 86400000, # Expired 1 day ago
quote_expires_at_ms=int(time.time() * 1000)
- 86400000, # Expired 1 day ago
confirmations_required=5,
)
self.expired_payment.status = CryptoPayment.STATUS_EXPIRED
dbsession.add(self.expired_payment)
# Create cancelled payment
self.cancelled_payment = CryptoPayment(
invoice=None,
@ -1292,12 +1297,13 @@ class PassiveMonitoringIntegrationTests(unittest.TestCase):
coin_type="XMR",
expected_amount=50000000000, # 0.05 XMR
rate_locked_usd_per_coin=150.0,
quote_expires_at_ms=int(time.time() * 1000) - 3600000, # Expired 1 hour ago
quote_expires_at_ms=int(time.time() * 1000)
- 3600000, # Expired 1 hour ago
confirmations_required=5,
)
self.cancelled_payment.status = CryptoPayment.STATUS_CANCELLED
dbsession.add(self.cancelled_payment)
dbsession.flush()
self.expired_payment_id = self.expired_payment.id
self.cancelled_payment_id = self.cancelled_payment.id
@ -1307,7 +1313,9 @@ class PassiveMonitoringIntegrationTests(unittest.TestCase):
self.engine.dispose()
@patch("make_post_sell.lib.crypto_watcher.get_crypto_client")
def test_passive_scan_processes_late_payment_to_expired_quote(self, mock_get_client):
def test_passive_scan_processes_late_payment_to_expired_quote(
self, mock_get_client
):
"""Integration test: passive scan finds and processes late payment to expired quote."""
# Mock XMR client with transfer matching expired payment's subaddress
mock_client = MagicMock()
@ -1317,7 +1325,10 @@ class PassiveMonitoringIntegrationTests(unittest.TestCase):
"txid": "late_payment_tx_123",
"amount": 100000000000, # Exact amount for expired payment
"confirmations": 10,
"subaddr_index": {"major": 0, "minor": 42}, # Matches expired payment
"subaddr_index": {
"major": 0,
"minor": 42,
}, # Matches expired payment
}
],
"pending": [],
@ -1327,26 +1338,28 @@ class PassiveMonitoringIntegrationTests(unittest.TestCase):
with transaction.manager:
dbsession = get_tm_session(self.session_factory, transaction.manager)
# Create mock request with real DB session
mock_request = MagicMock()
mock_request.dbsession = dbsession
mock_request.tm = transaction.manager
# Run passive scan
scan_wallet_for_late_payments(mock_request, {})
# Verify payment was updated
payment = dbsession.query(CryptoPayment).filter_by(
id=self.expired_payment_id
).first()
payment = (
dbsession.query(CryptoPayment)
.filter_by(id=self.expired_payment_id)
.first()
)
# Should have transitioned from expired to expired-refunded (if refund address exists)
# or no-refund if no refund address
self.assertIn(payment.status, [
CryptoPayment.STATUS_EXPIRED_REFUNDED,
CryptoPayment.STATUS_NO_REFUND
])
self.assertIn(
payment.status,
[CryptoPayment.STATUS_EXPIRED_REFUNDED, CryptoPayment.STATUS_NO_REFUND],
)
self.assertEqual(payment.received_amount, 100000000000)
def test_passive_scan_with_multiple_late_payments(self):

View file

@ -227,12 +227,15 @@ def crypto_xmr_start(request):
from ..models.user_crypto_refund_address import get_user_crypto_refund_address
user_refund_addr_obj = get_user_crypto_refund_address(
request.dbsession, request.user, "XMR"
request.dbsession, request.user, request.shop, "XMR"
)
user_refund_address = (
user_refund_addr_obj.address if user_refund_addr_obj else None
)
# Convert fee to atomic units (piconero) for storage
estimated_fee_piconero = int(fee_buffer_xmr * 1_000_000_000_000)
# Persist CryptoPayment
crypto_payment = CryptoPayment(
invoice=invoice,
@ -243,6 +246,7 @@ def crypto_xmr_start(request):
subaddress_index=subaddr_index,
coin_type="XMR",
expected_amount=expected_piconero,
estimated_fee_amount=estimated_fee_piconero,
rate_locked_usd_per_coin=usd_per_xmr,
quote_expires_at_ms=quote_expires_at_ms,
confirmations_required=confirmations_required,
@ -461,12 +465,15 @@ def crypto_doge_start(request):
from ..models.user_crypto_refund_address import get_user_crypto_refund_address
user_refund_addr_obj = get_user_crypto_refund_address(
request.dbsession, request.user, "DOGE"
request.dbsession, request.user, request.shop, "DOGE"
)
user_refund_address = (
user_refund_addr_obj.address if user_refund_addr_obj else None
)
# Convert fee to atomic units (koinu) for storage
estimated_fee_koinu = int(fee_buffer_doge * 100_000_000)
# Persist CryptoPayment (using account_index=0, subaddress_index=0 for Bitcoin-like coins)
crypto_payment = CryptoPayment(
invoice=invoice,
@ -477,6 +484,7 @@ def crypto_doge_start(request):
subaddress_index=0, # Not used for Bitcoin-like coins
coin_type="DOGE",
expected_amount=expected_koinu,
estimated_fee_amount=estimated_fee_koinu,
rate_locked_usd_per_coin=usd_per_doge,
quote_expires_at_ms=quote_expires_at_ms,
confirmations_required=confirmations_required,
@ -549,7 +557,7 @@ def crypto_quote(request):
from ..models.user_crypto_refund_address import get_user_crypto_refund_address
user_refund_addr_obj = (
get_user_crypto_refund_address(request.dbsession, request.user, coin_type)
get_user_crypto_refund_address(request.dbsession, request.user, request.shop, coin_type)
if request.user
else None
)
@ -572,31 +580,18 @@ def crypto_quote(request):
crypto_payment.expected_amount / coin_info["smallest_unit_divisor"]
)
# Estimate base amount and fee (this is approximate since we don't store it separately)
settings = request.registry.settings
# Calculate the fee the same way it was calculated during payment creation
if coin_type == "XMR" and crypto_payment.shop_sweep_to_address:
# Calculate base amount from the total
base_amount_crypto = usd_total / float(crypto_payment.rate_locked_usd_per_coin)
base_amount_piconero = int(
base_amount_crypto * coin_info["smallest_unit_divisor"]
)
fee_buffer = estimate_monero_fee_for_quote(
settings, crypto_payment.shop_sweep_to_address, base_amount_piconero
)
elif coin_type == "DOGE":
fee_buffer = estimate_dogecoin_fee_for_quote(settings)
# Use stored fee amount (calculated during payment creation) to avoid expensive RPC calls
if crypto_payment.estimated_fee_amount is not None:
# Convert stored atomic units back to coin units for display
fee_buffer = Decimal(crypto_payment.estimated_fee_amount) / coin_info["smallest_unit_divisor"]
else:
# Fallback to settings (but double it since all fees are doubled now)
fee_buffer = (
float(
settings.get(
f"{coin_type.lower()}.fee_buffer", coin_info["default_fee_buffer"]
)
# Fallback for old payments without stored fees
settings = request.registry.settings
fee_buffer = float(
settings.get(
f"{coin_type.lower()}.fee_buffer", coin_info["default_fee_buffer"]
)
* 2
)
) * 2
amount_crypto_base = amount_crypto_with_fee - float(fee_buffer)

View file

@ -35,10 +35,10 @@ def user_crypto_settings_view(request):
user = request.user
enabled_coins = get_enabled_coins(request)
# Get all user's crypto addresses
# Get all user's crypto addresses for current shop
addresses = {}
for coin in enabled_coins:
addr = get_user_crypto_refund_address(request.dbsession, user, coin)
addr = get_user_crypto_refund_address(request.dbsession, user, request.shop, coin)
if addr:
addresses[coin] = addr
@ -67,7 +67,7 @@ def user_crypto_settings_update(request):
if not address:
# If address is empty, treat it as a delete
addr = get_user_crypto_refund_address(request.dbsession, user, coin_type)
addr = get_user_crypto_refund_address(request.dbsession, user, request.shop, coin_type)
if addr:
request.dbsession.delete(addr)
request.session.flash((f"{coin_type} refund address cleared", "success"))
@ -80,7 +80,7 @@ def user_crypto_settings_update(request):
return HTTPFound("/u/settings/crypto")
# Get existing or create new
addr_obj = get_user_crypto_refund_address(request.dbsession, user, coin_type)
addr_obj = get_user_crypto_refund_address(request.dbsession, user, request.shop, coin_type)
if addr_obj:
# Update existing
@ -92,6 +92,7 @@ def user_crypto_settings_update(request):
# Create new
addr_obj = UserCryptoRefundAddress(
user=user,
shop=request.shop,
coin_type=coin_type,
address=address,
label=label if label else None,