Dogecoin payment support

This commit is contained in:
Russell Ballestrini 2025-09-23 11:17:05 +00:00
parent a6d6860d2a
commit 68d4ccfc25
21 changed files with 1974 additions and 56 deletions

View file

@ -74,4 +74,4 @@ Always use `uuid_str` when you need a string copy of the identifier. Models inhe
## Commit Message Guidelines
Do not include Claude Code attribution in commit messages.
**CRITICAL**: Do not include Claude Code attribution in commit messages. Attributing human work to Claude is inappropriate and misrepresents the actual authorship of the code. All code changes should be attributed to the human developer who reviewed, approved, and committed the work.

176
Makefile
View file

@ -50,6 +50,14 @@ help:
@echo " make crypto-watcher - Start payment monitoring service"
@echo " make crypto-watcher-once - Run payment check once (testing)"
@echo ""
@echo "CRYPTOCURRENCY (DOGECOIN):"
@echo " make check-dogecoin - Check if Dogecoin Core is installed"
@echo " make install-dogecoin - Install Dogecoin Core automatically"
@echo " make dogecoin-config - Create dogecoin.conf for pruned mode"
@echo " make dogecoin-node - Start Dogecoin daemon (pruned mode)"
@echo " make dogecoin-node-full - Start Dogecoin daemon (full blockchain)"
@echo " make dogecoin-status - Check sync status and wallet info"
@echo ""
@echo "WALLET MANAGEMENT:"
@echo " make sweep-check - Check hot wallet balances (dry run)"
@echo " make sweep - Sweep funds to cold storage"
@ -345,6 +353,174 @@ monero-full-stack:
@echo ""
@echo "First time? Run 'make monero-wallet-create' to create a wallet"
# -----------------------------------------------------------------------------
# Dogecoin Infrastructure Targets
# -----------------------------------------------------------------------------
# Check if Dogecoin Core is installed and provide install instructions
check-dogecoin:
@if command -v dogecoind >/dev/null 2>&1; then \
echo "✓ Dogecoin Core found: $$(dogecoind --version | head -1)"; \
else \
echo "❌ Dogecoin Core not found!"; \
echo ""; \
echo "Install options:"; \
echo "1. Automatic: make install-dogecoin"; \
echo "2. Manual: Download from https://github.com/dogecoin/dogecoin/releases"; \
echo ""; \
echo "See DOGECOIN_SETUP.md for detailed instructions"; \
exit 1; \
fi
# Install Dogecoin Core automatically
install-dogecoin:
@echo "Installing Dogecoin Core..."
@if [ "$$(uname)" = "Linux" ]; then \
mkdir -p $(HOME)/.local/bin && \
cd /tmp && \
echo "Downloading Dogecoin Core v1.14.6..." && \
wget -q --show-progress https://github.com/dogecoin/dogecoin/releases/download/v1.14.6/dogecoin-1.14.6-x86_64-linux-gnu.tar.gz && \
tar -xzf dogecoin-1.14.6-x86_64-linux-gnu.tar.gz && \
cp dogecoin-1.14.6/bin/* $(HOME)/.local/bin/ && \
rm -rf dogecoin-1.14.6* && \
echo "✓ Dogecoin Core installed to $(HOME)/.local/bin/" && \
echo "" && \
echo "Add to your PATH by running:" && \
echo " export PATH=\"$(HOME)/.local/bin:\$$PATH\"" && \
echo "Or add that line to your ~/.bashrc or ~/.zshrc" && \
echo "" && \
echo "Then run 'make dogecoin-config' to create configuration"; \
elif [ "$$(uname)" = "Darwin" ]; then \
if command -v brew >/dev/null 2>&1; then \
brew install dogecoin; \
else \
echo "Please install Homebrew first: https://brew.sh"; \
echo "Or download manually from: https://github.com/dogecoin/dogecoin/releases"; \
exit 1; \
fi; \
else \
echo "Unsupported OS. Please download manually from:"; \
echo "https://github.com/dogecoin/dogecoin/releases"; \
exit 1; \
fi
# Create dogecoin.conf for pruned hot wallet mode
dogecoin-config: check-dogecoin
@echo "Creating Dogecoin configuration for hot wallet (pruned mode)..."
@mkdir -p $(HOME)/.dogecoin
@if [ -f $(HOME)/.dogecoin/dogecoin.conf ]; then \
echo "Backing up existing config to dogecoin.conf.backup"; \
cp $(HOME)/.dogecoin/dogecoin.conf $(HOME)/.dogecoin/dogecoin.conf.backup; \
fi
@echo "# Dogecoin Core Configuration for Make Post Sell Hot Wallet" > $(HOME)/.dogecoin/dogecoin.conf
@echo "# Generated by: make dogecoin-config" >> $(HOME)/.dogecoin/dogecoin.conf
@echo "" >> $(HOME)/.dogecoin/dogecoin.conf
@echo "# Enable server mode for RPC" >> $(HOME)/.dogecoin/dogecoin.conf
@echo "server=1" >> $(HOME)/.dogecoin/dogecoin.conf
@echo "daemon=1" >> $(HOME)/.dogecoin/dogecoin.conf
@echo "" >> $(HOME)/.dogecoin/dogecoin.conf
@echo "# RPC credentials - CHANGE THESE IN PRODUCTION!" >> $(HOME)/.dogecoin/dogecoin.conf
@echo "rpcuser=mps_doge_user" >> $(HOME)/.dogecoin/dogecoin.conf
@echo "rpcpassword=change_this_password_in_production" >> $(HOME)/.dogecoin/dogecoin.conf
@echo "rpcallowip=127.0.0.1" >> $(HOME)/.dogecoin/dogecoin.conf
@echo "rpcport=22555" >> $(HOME)/.dogecoin/dogecoin.conf
@echo "" >> $(HOME)/.dogecoin/dogecoin.conf
@echo "# PRUNED MODE - keeps only ~2GB instead of 50GB!" >> $(HOME)/.dogecoin/dogecoin.conf
@echo "prune=2000" >> $(HOME)/.dogecoin/dogecoin.conf
@echo "" >> $(HOME)/.dogecoin/dogecoin.conf
@echo "# Connect to reliable peers for faster sync" >> $(HOME)/.dogecoin/dogecoin.conf
@echo "addnode=seed.dogechain.info" >> $(HOME)/.dogecoin/dogecoin.conf
@echo "addnode=seed.multidoge.org" >> $(HOME)/.dogecoin/dogecoin.conf
@echo "addnode=seed.dogecoin.com" >> $(HOME)/.dogecoin/dogecoin.conf
@echo "" >> $(HOME)/.dogecoin/dogecoin.conf
@echo "# Hot wallet for Make Post Sell" >> $(HOME)/.dogecoin/dogecoin.conf
@echo "wallet=make_post_sell_hot_wallet" >> $(HOME)/.dogecoin/dogecoin.conf
@echo ""
@echo "✓ Configuration created at: $(HOME)/.dogecoin/dogecoin.conf"
@echo ""
@echo "🔐 SECURITY WARNING: Change the RPC password before production use!"
@echo "Edit $(HOME)/.dogecoin/dogecoin.conf and update rpcpassword"
@echo ""
@echo "Next steps:"
@echo "1. make dogecoin-node # Start pruned node (recommended)"
@echo "2. make dogecoin-status # Check sync progress"
# Start Dogecoin daemon in pruned mode (recommended)
dogecoin-node: check-dogecoin
@echo "Starting Dogecoin daemon in PRUNED mode..."
@echo "This will use ~2GB storage instead of 50GB full blockchain"
@echo "Initial sync takes 2-4 hours (much faster than full node)"
@echo ""
@if [ ! -f $(HOME)/.dogecoin/dogecoin.conf ]; then \
echo "No config found. Run 'make dogecoin-config' first."; \
exit 1; \
fi
@echo "Data directory: $(HOME)/.dogecoin/"
@echo "RPC available at: http://127.0.0.1:22555"
@echo "Check sync status: make dogecoin-status"
@echo "Press Ctrl+C to stop"
@echo ""
dogecoind
# Start Dogecoin daemon in full mode (complete blockchain)
dogecoin-node-full: check-dogecoin
@echo "Starting Dogecoin daemon in FULL mode..."
@echo "This will download the complete ~50GB blockchain"
@echo "Initial sync takes 8-12 hours"
@echo ""
@mkdir -p $(DATA_DIR)/dogecoin-blockchain
@echo "Checking available disk space..."
@available=$$(df -BG $(DATA_DIR) | tail -1 | awk '{print $$4}' | sed 's/G//'); \
if [ $$available -lt 60 ]; then \
echo "ERROR: Insufficient disk space!"; \
echo "Available: $${available}GB"; \
echo "Required: 60GB+ (50GB blockchain + growth)"; \
exit 1; \
else \
echo "Disk space OK: $${available}GB available"; \
fi
@echo "Data directory: $(DATA_DIR)/dogecoin-blockchain"
@echo "RPC available at: http://127.0.0.1:22555"
@echo "Check sync status: make dogecoin-status"
@echo "Press Ctrl+C to stop"
@echo ""
dogecoind -datadir=$(DATA_DIR)/dogecoin-blockchain \
-rpcbind=127.0.0.1 \
-rpcport=22555 \
-rpcuser=mps_doge_user \
-rpcpassword=change_this_password_in_production \
-rpcallowip=127.0.0.1 \
-server=1
# Check Dogecoin sync status and wallet info
dogecoin-status: check-dogecoin
@echo "=== Dogecoin Node Status ==="
@echo ""
@if ! dogecoin-cli getblockchaininfo >/dev/null 2>&1; then \
echo "❌ Dogecoin daemon not running or not responding"; \
echo "Start with: make dogecoin-node"; \
exit 1; \
fi
@echo "Blockchain info:"
@dogecoin-cli getblockchaininfo | grep -E "(chain|blocks|headers|verificationprogress|size_on_disk|pruned)"
@echo ""
@echo "Network info:"
@dogecoin-cli getnetworkinfo | grep -E "(version|subversion|connections)"
@echo ""
@echo "Wallet info:"
@dogecoin-cli getwalletinfo | grep -E "(walletname|balance|unconfirmed_balance)" || echo "No wallet loaded"
@echo ""
@blocks=$$(dogecoin-cli getblockchaininfo | grep '"blocks"' | cut -d: -f2 | tr -d ' ,'); \
headers=$$(dogecoin-cli getblockchaininfo | grep '"headers"' | cut -d: -f2 | tr -d ' ,'); \
if [ "$$blocks" = "$$headers" ]; then \
echo "✓ Sync complete! Blocks: $$blocks"; \
else \
echo "⏳ Syncing... Blocks: $$blocks / Headers: $$headers"; \
progress=$$(dogecoin-cli getblockchaininfo | grep verificationprogress | cut -d: -f2 | tr -d ' ,'); \
percent=$$(echo "$$progress * 100" | bc -l | cut -d. -f1); \
echo "Progress: $$percent%"; \
fi
# -----------------------------------------------------------------------------
# Cleanup Target
# -----------------------------------------------------------------------------

View file

@ -67,6 +67,7 @@ app.stripe.test_mode = True
# Payment method toggles
app.payments.stripe.enabled = ${MPS_PAYMENTS_STRIPE_ENABLED:-True}
app.payments.monero.enabled = ${MPS_PAYMENTS_MONERO_ENABLED:-False}
app.payments.dogecoin.enabled = ${MPS_PAYMENTS_DOGECOIN_ENABLED:-False}
# Monero RPC Configuration
# RPC endpoint of monero-wallet-rpc (recommend binding to localhost only)
@ -82,6 +83,19 @@ monero.confirmations.high = ${MPS_MONERO_CONFIRMATIONS_HIGH:-20}
monero.quote_expiry_seconds = ${MPS_MONERO_QUOTE_EXPIRY_SECONDS:-900}
monero.rate_source_url = ${MPS_MONERO_RATE_SOURCE_URL:-https://api.coingecko.com/api/v3/simple/price?ids=monero&vs_currencies=usd}
# Dogecoin RPC Configuration
# RPC endpoint of dogecoind (recommend binding to localhost only)
dogecoin.rpc_url = ${MPS_DOGECOIN_RPC_URL:-http://127.0.0.1:22555}
dogecoin.rpc_user = ${MPS_DOGECOIN_RPC_USER:-mps_doge_user}
dogecoin.rpc_pass = ${MPS_DOGECOIN_RPC_PASS:-change_this_password_in_production}
# Dogecoin confirmation requirements by amount tier
# Note: Payment thresholds are now per-shop settings (default $10 and $100)
dogecoin.confirmations.petty = ${MPS_DOGECOIN_CONFIRMATIONS_PETTY:-2}
dogecoin.confirmations.mid = ${MPS_DOGECOIN_CONFIRMATIONS_MID:-6}
dogecoin.confirmations.high = ${MPS_DOGECOIN_CONFIRMATIONS_HIGH:-20}
# Fee buffer for customer payments (in DOGE)
dogecoin.fee_buffer = ${MPS_DOGECOIN_FEE_BUFFER:-0.01}
dogecoin.rate_source_url = ${MPS_DOGECOIN_RATE_SOURCE_URL:-https://api.coingecko.com/api/v3/simple/price?ids=dogecoin&vs_currencies=usd}
# set this to the path of your private DKIM key.
# reference: https://russell.ballestrini.net/quickstart-to-dkim-signed-email-with-python/

151
docs/DOGECOIN.md Normal file
View file

@ -0,0 +1,151 @@
# Dogecoin Setup Guide 🐕
Make Post Sell supports Dogecoin payments using **Dogecoin Core in pruned mode** - just like mobile wallets but for servers!
## Recommended Setup: Pruned Node Mode ✨
**Minimal blockchain storage** with **full hot wallet functionality**.
### 1. Install Dogecoin Core
```bash
# Download latest release
wget https://github.com/dogecoin/dogecoin/releases/download/v1.14.6/dogecoin-1.14.6-x86_64-linux-gnu.tar.gz
tar -xzf dogecoin-1.14.6-x86_64-linux-gnu.tar.gz
sudo mv dogecoin-1.14.6/bin/* /usr/local/bin/
```
### 2. Configure Pruned Mode
Create `~/.dogecoin/dogecoin.conf`:
```ini
# Enable server mode for RPC
server=1
daemon=1
# RPC credentials
rpcuser=your_rpc_user
rpcpassword=your_very_secure_rpc_password
rpcallowip=127.0.0.1
rpcport=22555
# PRUNED MODE - keeps only ~2GB instead of 50GB!
prune=2000
# Connect to reliable peers (faster sync)
addnode=seed.dogechain.info
addnode=seed.multidoge.org
addnode=seed.dogecoin.com
# Wallet settings
wallet=make_post_sell_hot_wallet
```
### 3. Start & Sync (Much Faster!)
```bash
# Start dogecoin daemon
dogecoind
# Initial sync takes ~2-4 hours (vs 12+ hours for full node)
# Watch progress:
dogecoin-cli getblockchaininfo
# When "blocks" equals "headers", sync is complete
```
### 4. Configure Make Post Sell
```ini
# Enable Dogecoin payments
payments.dogecoin.enabled = true
# Connect to local pruned node
dogecoin.rpc_url = http://localhost:22555
dogecoin.rpc_user = your_rpc_user
dogecoin.rpc_pass = your_very_secure_rpc_password
# Confirmation settings
dogecoin.confirmations.petty = 2 # < $10: 2 confirmations
dogecoin.confirmations.mid = 6 # $10-$100: 6 confirmations
dogecoin.confirmations.high = 20 # > $100: 20 confirmations
# Fee buffer for customer payments
dogecoin.fee_buffer = 0.01
```
### ✅ Why This Works Perfectly
**Like Mobile Apps:**
- ✅ **Real hot wallet** with private keys
- ✅ **Connects to remote peers** for blockchain data
- ✅ **Minimal storage** (~2GB vs 50GB)
- ✅ **Full RPC functionality** (send, receive, sweep)
- ✅ **Fast sync** (2-4 hours vs 12+ hours)
**Production Ready:**
- ✅ **Auto-sweep enabled**
- ✅ **True address generation**
- ✅ **No API dependencies**
- ✅ **Decentralized operation**
## Alternative: Full Node Mode
If you need maximum decentralization, you can run without pruning:
```ini
# Full node (in dogecoin.conf) - removes prune=2000
server=1
daemon=1
rpcuser=your_rpc_user
rpcpassword=your_very_secure_rpc_password
```
**Trade-offs:**
- ✅ Complete blockchain history
- ❌ ~50GB storage requirement
- ❌ 12+ hour initial sync
## Shop Configuration
Both modes require shop owners to:
1. **Enable Dogecoin** in shop settings
2. **Configure cold wallet** address where funds will be swept
3. **Set risk thresholds** for confirmation requirements
## Payment Flow
1. **Customer Checkout**: Selects "Pay with Dogecoin 🐕"
2. **Quote Generation**: Real-time USD/DOGE rate from CoinGecko
3. **Address Creation**: Unique address per payment
4. **Payment Monitoring**: 20-second polling for confirmations
5. **Auto-Finalization**: Products unlocked when confirmed
6. **Auto-Sweep**: Automatic sweep to cold wallet when confirmed
## Monitoring & Logs
```bash
# Watch the crypto watcher logs
tail -f /path/to/logs/crypto_watcher.log
# Key log messages:
# "Processing 1 DOGE payments"
# "Found 2 incoming DOGE transfers for address D..."
# "DOGE auto-sweep successful for payment [uuid]"
```
## Troubleshooting
### Full Node Issues
- **Sync Problems**: Check `dogecoin.conf` and network connectivity
- **RPC Errors**: Verify credentials and `rpcallowip` settings
- **Storage Full**: Use `prune=10000` to limit blockchain storage
## Security Notes
- **Cold Wallets**: Always use hardware wallets or offline storage for shop sweep addresses
- **RPC Security**: Never expose RPC ports to the internet
- **Private Keys**: Node stores keys on disk - secure the server properly
---
**Much setup! Such payments! Very wow! 🐕🚀**
Ready to accept DOGE payments with zero blockchain bloat!

View file

@ -26,11 +26,9 @@ class MoneroClient:
self.timeout = timeout
def _headers(self) -> Dict[str, str]:
headers = {"Content-Type": "application/json"}
if self.rpc_user and self.rpc_pass:
auth = f"{self.rpc_user}:{self.rpc_pass}".encode()
headers["Authorization"] = "Basic " + base64.b64encode(auth).decode()
return headers
# MoneroClient now uses Digest auth in _call method
# This method is only used for no-auth requests
return {"Content-Type": "application/json"}
def _call(self, method: str, params: Optional[Dict[str, Any]] = None) -> Any:
payload = {
@ -41,16 +39,42 @@ class MoneroClient:
if params is not None:
payload["params"] = params
data = json.dumps(payload).encode()
req = urllib.request.Request(self.rpc_url, data=data, headers=self._headers())
try:
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
body = resp.read()
obj = json.loads(body)
if "error" in obj and obj["error"]:
raise RuntimeError(obj["error"]) # bubble up rpc error
return obj.get("result")
except urllib.error.URLError as e:
raise RuntimeError(f"Monero RPC connection error: {e}")
# Monero uses Digest authentication, not Basic
# We need to handle this differently from the headers approach
if self.rpc_user and self.rpc_pass:
# Create a password manager for Digest auth
password_mgr = urllib.request.HTTPPasswordMgrWithDefaultRealm()
password_mgr.add_password(None, self.rpc_url, self.rpc_user, self.rpc_pass)
# Create the Digest auth handler
auth_handler = urllib.request.HTTPDigestAuthHandler(password_mgr)
opener = urllib.request.build_opener(auth_handler)
# Make the request with Digest auth
req = urllib.request.Request(self.rpc_url, data=data,
headers={"Content-Type": "application/json"})
try:
with opener.open(req, timeout=self.timeout) as resp:
body = resp.read()
obj = json.loads(body)
if "error" in obj and obj["error"]:
raise RuntimeError(obj["error"]) # bubble up rpc error
return obj.get("result")
except urllib.error.URLError as e:
raise RuntimeError(f"Monero RPC connection error: {e}")
else:
# No auth needed
req = urllib.request.Request(self.rpc_url, data=data, headers=self._headers())
try:
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
body = resp.read()
obj = json.loads(body)
if "error" in obj and obj["error"]:
raise RuntimeError(obj["error"]) # bubble up rpc error
return obj.get("result")
except urllib.error.URLError as e:
raise RuntimeError(f"Monero RPC connection error: {e}")
# High-level helpers
@ -330,3 +354,27 @@ def get_client_from_settings(settings) -> MoneroClient:
rpc_user=settings.get("monero.rpc_user"),
rpc_pass=settings.get("monero.rpc_pass"),
)
def get_dogecoin_client_from_settings(settings):
"""
Helper to construct a Dogecoin client from Pyramid settings.
Uses standard Dogecoin Core RPC - works with both full and pruned nodes.
Pruned mode recommended: only ~2GB storage vs 50GB for full node.
Settings:
dogecoin.rpc_url, dogecoin.rpc_user, dogecoin.rpc_pass
"""
if str(settings.get("dogecoin.mock", "false")).lower() in ("1", "true", "yes"):
return MockDogecoinClient()
rpc_url = settings.get("dogecoin.rpc_url")
if not rpc_url:
raise RuntimeError("dogecoin.rpc_url not configured")
return DogecoinClient(
rpc_url=rpc_url,
rpc_user=settings.get("dogecoin.rpc_user"),
rpc_pass=settings.get("dogecoin.rpc_pass"),
)

View file

@ -5,10 +5,11 @@ import time
import logging
from typing import List
from decimal import Decimal
from urllib.parse import urlparse
from pyramid.paster import bootstrap, setup_logging
from .crypto_clients import get_client_from_settings
from .crypto_clients import get_client_from_settings, get_dogecoin_client_from_settings
from ..models.crypto_payment import CryptoPayment
from ..models.invoice import Invoice
from .mail import send_purchase_email, send_sale_email
@ -19,14 +20,51 @@ from ..models.meta import now_timestamp
logger = logging.getLogger(__name__)
# Constants
ATOMIC_UNITS = Decimal("1000000000000") # 1 XMR = 10^12 atomic units
MIN_SWEEP_BALANCE = Decimal("0.001") # Keep minimal 0.001 XMR for ~7-8 transaction fees
COIN_CONFIGS = {
"XMR": {
"atomic_units": Decimal("1000000000000"), # 1 XMR = 10^12 piconero
"min_sweep_balance": Decimal("0.001"), # Keep minimal 0.001 XMR for fees
},
"DOGE": {
"atomic_units": Decimal("100000000"), # 1 DOGE = 10^8 koinu
"min_sweep_balance": Decimal("0.1"), # Keep minimal 0.1 DOGE for fees
},
}
def get_crypto_client(settings, coin_type):
"""Get the appropriate crypto client for the given coin type."""
if coin_type == "XMR":
return get_client_from_settings(settings)
elif coin_type == "DOGE":
return get_dogecoin_client_from_settings(settings)
else:
raise ValueError(f"Unsupported coin type: {coin_type}")
def get_coin_config(coin_type):
"""Get configuration for a specific coin type."""
return COIN_CONFIGS.get(coin_type, COIN_CONFIGS["XMR"])
def auto_sweep_payment(client, crypto_payment: CryptoPayment):
"""Auto-sweep funds from a confirmed payment to the shop's cold wallet."""
logger.info(f"Starting auto-sweep check for payment {crypto_payment.id}")
# Dispatch to coin-specific sweep function
if crypto_payment.coin_type == "XMR":
return auto_sweep_payment_xmr(client, crypto_payment)
elif crypto_payment.coin_type == "DOGE":
return auto_sweep_payment_doge(client, crypto_payment)
else:
logger.error(f"Unsupported coin type for sweep: {crypto_payment.coin_type}")
return False
def auto_sweep_payment_xmr(client, crypto_payment: CryptoPayment):
"""Auto-sweep XMR funds from a confirmed payment to the shop's cold wallet."""
logger.info(f"Starting XMR auto-sweep check for payment {crypto_payment.id}")
if not crypto_payment.shop_sweep_to_address:
logger.info(f"Payment {crypto_payment.id} has no sweep address configured")
return False
@ -41,17 +79,20 @@ def auto_sweep_payment(client, crypto_payment: CryptoPayment):
try:
# Get balance for the account
coin_config = get_coin_config("XMR")
atomic_units = coin_config["atomic_units"]
result = client._call(
"get_balance", {"account_index": crypto_payment.account_index}
)
unlocked_balance = Decimal(result.get("unlocked_balance", 0)) / ATOMIC_UNITS
unlocked_balance = Decimal(result.get("unlocked_balance", 0)) / atomic_units
logger.info(
f"Account {crypto_payment.account_index} unlocked balance: {unlocked_balance} XMR"
)
# Calculate sweep amount for THIS SPECIFIC payment only
payment_amount_xmr = Decimal(crypto_payment.received_amount) / ATOMIC_UNITS
payment_amount_xmr = Decimal(crypto_payment.received_amount) / atomic_units
# If no balance, mark as already swept (another payment swept it)
if unlocked_balance == 0:
@ -87,7 +128,7 @@ def auto_sweep_payment(client, crypto_payment: CryptoPayment):
# For sweep_all, the amount_list contains the actual amounts swept
amount_list = result.get("amount_list", [])
total_swept = (
sum(amount_list) if amount_list else unlocked_balance * ATOMIC_UNITS
sum(amount_list) if amount_list else unlocked_balance * atomic_units
)
crypto_payment.swept_amount = int(total_swept)
@ -107,6 +148,133 @@ def auto_sweep_payment(client, crypto_payment: CryptoPayment):
return False
def auto_sweep_payment_doge(client, crypto_payment: CryptoPayment):
"""Auto-sweep DOGE funds from a confirmed payment to the shop's cold wallet."""
logger.info(f"Starting DOGE auto-sweep check for payment {crypto_payment.id}")
if not crypto_payment.shop_sweep_to_address:
logger.info(f"Payment {crypto_payment.id} has no sweep address configured")
return False
if crypto_payment.is_swept:
logger.info(f"Payment {crypto_payment.id} already swept")
return True
logger.info(
f"Payment {crypto_payment.id} needs sweep to {crypto_payment.shop_sweep_to_address}"
)
try:
coin_config = get_coin_config("DOGE")
atomic_units = coin_config["atomic_units"]
min_sweep_balance = coin_config["min_sweep_balance"]
# Get wallet balance
balance = Decimal(str(client.getbalance()))
logger.info(f"Wallet balance: {balance} DOGE")
# Calculate payment amount in DOGE
payment_amount_doge = Decimal(crypto_payment.received_amount) / atomic_units
# If balance is too low, mark as already swept (another payment swept it)
if balance < min_sweep_balance:
logger.info(
f"Balance too low to sweep for payment {crypto_payment.id} - marking as swept"
)
crypto_payment.swept_amount = crypto_payment.received_amount
crypto_payment.swept_tx_hash = "pooled_sweep"
crypto_payment.swept_timestamp = now_timestamp()
return True
# Sweep all balance minus a small buffer for fees
sweep_amount = balance - min_sweep_balance
if sweep_amount <= 0:
logger.info(
f"No funds to sweep after fee buffer for payment {crypto_payment.id}"
)
return False
logger.info(
f"Sweeping {sweep_amount} DOGE for payment {crypto_payment.id} to {crypto_payment.shop_sweep_to_address}"
)
# Send the sweep transaction
tx_hash = client.sendtoaddress(
crypto_payment.shop_sweep_to_address,
float(sweep_amount),
f"Sweep for invoice {crypto_payment.invoice.id}",
)
if tx_hash:
# Mark this payment as swept
swept_amount_koinu = int(sweep_amount * atomic_units)
crypto_payment.swept_amount = swept_amount_koinu
crypto_payment.swept_tx_hash = tx_hash
crypto_payment.swept_timestamp = now_timestamp()
# Note: DOGE RPC doesn't return fee info easily, so we leave it None
crypto_payment.swept_network_fee = None
logger.info(
f"DOGE auto-sweep successful for payment {crypto_payment.id}! TX: {tx_hash}, Swept: {sweep_amount} DOGE"
)
return True
else:
logger.error(f"DOGE sweep failed for payment {crypto_payment.id}")
return False
except Exception as e:
logger.error(f"DOGE auto-sweep error for payment {crypto_payment.id}: {e}")
return False
def get_dogecoin_incoming_transfers(client, crypto_payment: CryptoPayment):
"""Get incoming transfers for a Dogecoin payment address."""
try:
address = crypto_payment.address
# Get received amount by address (with minimum 1 confirmation)
received_amount = client.getreceivedbyaddress(address, 1)
# Get recent transactions for this address
transactions = client.listtransactions(
"*", 100, 0
) # Get recent 100 transactions
# Filter for transactions to our address
incoming_txs = []
for tx in transactions:
if (
tx.get("address") == address
and tx.get("category") == "receive"
and tx.get("amount", 0) > 0
):
# Convert to format similar to Monero transfers
incoming_txs.append(
{
"txid": tx.get("txid", ""),
"amount": int(
tx.get("amount", 0) * 100_000_000
), # Convert to koinu
"confirmations": tx.get("confirmations", 0),
"address": address,
}
)
logger.info(
f"Found {len(incoming_txs)} incoming DOGE transfers for address {address}"
)
return incoming_txs
except Exception as e:
logger.error(
f"Error getting DOGE transfers for payment {crypto_payment.id}: {e}"
)
return []
def parse_args(argv):
p = argparse.ArgumentParser(
description="Crypto watcher: confirm payments and finalize invoices"
@ -140,6 +308,43 @@ def summarize_txs(transfers: List[dict]):
# Removed mark_payment_voided - no longer needed without Payment model
class ShopContextRequestWrapper:
"""Wrapper to provide shop domain context for email generation in crypto watcher."""
def __init__(self, original_request, shop):
self._original_request = original_request
self._shop = shop
# Use shop's domain_name if available, otherwise fallback to original request
if shop and shop.domain_name:
self._domain = shop.domain_name
# Construct full URL for the shop's domain
self._host_url = f"https://{shop.domain_name}"
else:
# Fallback to original request values
self._domain = getattr(original_request, "domain", "localhost")
self._host_url = getattr(original_request, "host_url", "http://localhost")
def __getattr__(self, name):
# Proxy all other attributes to the original request
return getattr(self._original_request, name)
@property
def domain(self):
return self._domain
@property
def host_url(self):
return self._host_url
def create_shop_context_request(env_request, crypto_payment: CryptoPayment):
"""Create a request wrapper with shop domain context for email generation."""
# Get shop through the relationship: crypto_payment -> invoice -> shop
shop = crypto_payment.invoice.shop if crypto_payment.invoice else None
return ShopContextRequestWrapper(env_request, shop)
def finalize_invoice(env_request, crypto_payment: CryptoPayment):
invoice: Invoice = crypto_payment.invoice
@ -162,14 +367,17 @@ def finalize_invoice(env_request, crypto_payment: CryptoPayment):
pass
if email_enabled:
# Create a request wrapper with shop's domain context for emails
email_request = create_shop_context_request(env_request, crypto_payment)
send_purchase_email(
env_request,
email_request,
invoice.user.email,
[item.product for item in invoice.line_items],
invoice.total,
)
send_sale_email(
env_request,
email_request,
invoice.shop,
[item.product for item in invoice.line_items],
invoice.total,
@ -218,9 +426,10 @@ def process_payment(
):
total_recv, _, _ = summarize_txs(incoming_transfers)
if total_recv > 0:
received_xmr = Decimal(total_recv) / ATOMIC_UNITS
coin_config = get_coin_config(crypto_payment.coin_type)
received_crypto = Decimal(total_recv) / coin_config["atomic_units"]
refund_details = payment_rescue.handle_expired_payment(
crypto_payment, received_xmr, crypto_payment.invoice.user
crypto_payment, received_crypto, crypto_payment.invoice.user
)
if refund_details:
logger.info(
@ -297,8 +506,10 @@ def process_payment(
and crypto_payment.invoice
and crypto_payment.invoice.user
):
received_xmr = Decimal(crypto_payment.received_amount) / ATOMIC_UNITS
expected_xmr = Decimal(crypto_payment.expected_amount) / ATOMIC_UNITS
coin_config = get_coin_config(crypto_payment.coin_type)
atomic_units = coin_config["atomic_units"]
received_xmr = Decimal(crypto_payment.received_amount) / atomic_units
expected_xmr = Decimal(crypto_payment.expected_amount) / atomic_units
refund_details = payment_rescue.handle_overpayment(
crypto_payment,
@ -337,8 +548,10 @@ def process_payment(
):
# Only handle underpayment here (overpayment is handled after confirmation)
if crypto_payment.received_amount < crypto_payment.expected_amount:
received_xmr = Decimal(crypto_payment.received_amount) / ATOMIC_UNITS
expected_xmr = Decimal(crypto_payment.expected_amount) / ATOMIC_UNITS
coin_config = get_coin_config(crypto_payment.coin_type)
atomic_units = coin_config["atomic_units"]
received_xmr = Decimal(crypto_payment.received_amount) / atomic_units
expected_xmr = Decimal(crypto_payment.expected_amount) / atomic_units
refund_details = payment_rescue.handle_underpayment(
crypto_payment,
@ -397,7 +610,6 @@ def process_payment(
def run_once(env, interval):
request = env["request"]
settings = request.registry.settings
client = get_client_from_settings(settings)
logger.info("Crypto watcher starting payment processing cycle")
@ -415,19 +627,47 @@ def run_once(env, interval):
f"Found {len(payments)} payments to process: {[p.status for p in payments]}"
)
# Group payments by coin type to get appropriate clients
payments_by_coin = {}
for crypto_payment in payments:
logger.info(
f"Processing payment {crypto_payment.id} (status: {crypto_payment.status})"
)
# Query transfers for subaddress
res = (
client.get_transfers_for_subaddr(
crypto_payment.account_index, [crypto_payment.subaddress_index]
coin_type = crypto_payment.coin_type
if coin_type not in payments_by_coin:
payments_by_coin[coin_type] = []
payments_by_coin[coin_type].append(crypto_payment)
# Process each coin type separately
for coin_type, coin_payments in payments_by_coin.items():
logger.info(f"Processing {len(coin_payments)} {coin_type} payments")
try:
client = get_crypto_client(settings, coin_type)
except ValueError as e:
logger.error(f"Failed to get client for {coin_type}: {e}")
continue
for crypto_payment in coin_payments:
logger.info(
f"Processing payment {crypto_payment.id} (status: {crypto_payment.status}, coin: {crypto_payment.coin_type})"
)
or {}
)
incoming = res.get("in", []) or []
process_payment(request, crypto_payment, incoming, client)
# Query transfers for the payment address
if coin_type == "XMR":
# Monero: Query transfers for subaddress
res = (
client.get_transfers_for_subaddr(
crypto_payment.account_index,
[crypto_payment.subaddress_index],
)
or {}
)
incoming = res.get("in", []) or []
elif coin_type == "DOGE":
# Dogecoin: Query received by address
incoming = get_dogecoin_incoming_transfers(client, crypto_payment)
else:
logger.error(f"Unsupported coin type for monitoring: {coin_type}")
incoming = []
process_payment(request, crypto_payment, incoming, client)
def main(argv=sys.argv):

View file

@ -136,3 +136,13 @@ class CryptoPayment(RBase, Base):
return 0
# Return the received amount for this specific payment
return self.received_amount
@property
def confirmation_status(self) -> str:
"""Get confirmation status as a user-friendly string (e.g., '2/10')."""
return f"{self.current_confirmations}/{self.confirmations_required}"
@property
def is_fully_confirmed(self) -> bool:
"""Check if payment has reached required confirmation count."""
return self.current_confirmations >= self.confirmations_required

View file

@ -246,6 +246,22 @@ class Shop(RBase, Base):
if processor:
return True
# If Dogecoin is enabled, check if shop has configured processor and RPC is available
if request.dogecoin_enabled and request.dogecoin_rpc_available:
from .crypto_processor import CryptoProcessor
processor = (
request.dbsession.query(CryptoProcessor)
.filter(
CryptoProcessor.shop_id == self.id,
CryptoProcessor.coin_type == "DOGE",
CryptoProcessor.enabled == True,
)
.first()
)
if processor:
return True
# No payment methods available or properly configured
return False

View file

@ -207,6 +207,33 @@ def includeme(config):
except Exception:
return False
def add_dogecoin_enabled(request):
"""Check if Dogecoin payments are enabled globally."""
try:
val = request.app.get("payments.dogecoin.enabled")
if isinstance(val, str):
return val.strip().lower() in ("1", "true", "yes", "on")
elif isinstance(val, bool):
return val
except Exception:
pass
return False # Default to disabled
def add_dogecoin_rpc_available(request):
"""Check if Dogecoin RPC is available and responding."""
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)
# Try to get blockchain height as a simple health check
height = client.getblockcount()
return height > 0
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)
@ -242,6 +269,10 @@ def includeme(config):
config.add_request_method(
add_monero_rpc_available, "monero_rpc_available", 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
)
def add_has_xmr_refund_address(request):
"""Check if the current user has an XMR refund address configured."""
@ -254,7 +285,21 @@ def includeme(config):
)
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:
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"
)
return refund_address is not None and refund_address.address is not None
# Refund address checks
config.add_request_method(
add_has_xmr_refund_address, "has_xmr_refund_address", reify=True
)
config.add_request_method(
add_has_doge_refund_address, "has_doge_refund_address", reify=True
)

View file

@ -150,4 +150,6 @@ def includeme(config):
# cryptocurrency payment routes.
config.add_route("crypto_xmr_start", "/crypto/xmr/start")
config.add_route("crypto_xmr_status", "/crypto/xmr/status/{payment_id}")
config.add_route("crypto_doge_start", "/crypto/doge/start")
config.add_route("crypto_doge_status", "/crypto/doge/status/{payment_id}")
config.add_route("crypto_quote", "/crypto/quote/{payment_id}")

View file

@ -63,6 +63,35 @@
</form>
{% endif %}
{# Show pending crypto quotes if user has any #}
{% if pending_crypto_quotes %}
<div style="background:#fff3cd; border:1px solid #ffc107; border-radius:4px; padding:15px; margin:20px 0;">
<h3 style="margin-top:0;">📋 Your Pending Crypto Quotes</h3>
<p style="margin-bottom:15px;">You have {{ pending_crypto_quotes|length }} pending crypto payment{{ 's' if pending_crypto_quotes|length != 1 else '' }}:</p>
{% for quote in pending_crypto_quotes %}
<div style="background:#f8f9fa; border:1px solid #dee2e6; border-radius:3px; padding:10px; margin:8px 0;">
<div style="display:flex; justify-content:space-between; align-items:center;">
<div>
<strong>{{ quote.coin_type }} Payment</strong>
<span style="color:#666; margin-left:10px;">
Status: {{ quote.status.title() }}
{% if quote.current_confirmations > 0 %}
({{ quote.confirmation_status }})
{% endif %}
</span>
</div>
<a href="{{ request.route_url('crypto_quote', payment_id=quote.id) }}" class="mps-button" style="background:#17a2b8; font-size:12px; padding:5px 10px;">View Quote</a>
</div>
<div style="font-size:12px; color:#666; margin-top:5px;">
Amount: {{ '%.8f' % (quote.expected_amount / (1000000000000 if quote.coin_type == 'XMR' else 100000000)) }} {{ quote.coin_type }}
</div>
</div>
{% endfor %}
<p style="margin-bottom:0; font-size:14px; color:#856404;">
💡 Complete an existing payment or create a new quote below.
</p>
</div>
{% endif %}
{# Offer Monero if globally enabled, shop has enabled processor, cart requires payment and is single-shop #}
{% if monero_enabled and xmr_processor_enabled and cart.requires_payment and cart.shop_product_dict|length == 1 %}
@ -79,7 +108,22 @@
{% endif %}
{% endif %}
{% if not stripe_enabled and not (monero_enabled and xmr_processor_enabled) %}
{# Offer Dogecoin if globally enabled, shop has enabled processor, cart requires payment and is single-shop #}
{% if dogecoin_enabled and doge_processor_enabled and cart.requires_payment and cart.shop_product_dict|length == 1 %}
<br/>
<form method="POST" action="{{ request.route_url('crypto_doge_start') }}">
{% include "snippets/csrf.j2" %}
<input type="hidden" name="cart_id" value="{{ cart.id }}" />
<button type="submit" class="mps-button" style="background:#d4a574;">Pay with Dogecoin (DOGE) 🐕</button>
</form>
{% if not request.has_doge_refund_address %}
<a href="/u/settings/crypto" target="_blank" style="color:#f59e0b; text-decoration:none; display:block; margin-top:10px; font-size:14px;">
⚠️ Configure a DOGE refund address to enable automatic refunds during payment errors.
</a>
{% endif %}
{% 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>
{% endif %}

View file

@ -21,8 +21,8 @@
</div>
<p>Status: <span id="status">{{ status }}</span></p>
<p id="confirmations" style="display:none;">
Confirmations: <span id="current-confirmations">0</span> / <span id="required-confirmations">0</span>
<p id="confirmations" {% if status == 'pending' or status == 'expired' %}style="display:none;"{% endif %}>
Confirmations: <span id="current-confirmations">{{ current_confirmations or 0 }}</span> / <span id="required-confirmations">{{ confirmations_required or 0 }}</span>
</p>
<p>
{% if expires_at %}

View file

@ -32,6 +32,12 @@
{% if crypto_pay.tx_hashes and crypto_pay.tx_hashes != '[]' %}
<p><strong>Transaction(s):</strong> <code style="font-size: 0.9em; font-family: monospace;">{{ crypto_pay.tx_hashes }}</code></p>
{% endif %}
<p style="margin-top: 15px;">
<a href="{{ request.route_url('crypto_quote', payment_id=crypto_pay.id) }}" class="mps-button" style="background: #17a2b8; color: white; text-decoration: none; padding: 8px 16px; border-radius: 4px; display: inline-block;">
📊 View Live Quote & Payment Status
</a>
</p>
</div>
{% endif %}

View file

@ -322,6 +322,82 @@
<br />
{% endif %}
{% if request.dogecoin_enabled %}
<section class="one-column">
<section class="shop-settings well">
<h4>Dogecoin (DOGE) Configuration 🐕</h4>
<form method="post" action="/s/{{ request.shop.id }}/crypto-processor/DOGE" onsubmit="submit.disabled = true; return true;">
{% include "snippets/csrf.j2" %}
<label for="doge_sweep_to_address_input">Cold Wallet Address (Required to accept Dogecoin)</label>
<input
type = "text"
name = "sweep_to_address"
id = "doge_sweep_to_address_input"
required
value = "{% if doge_processor %}{{ doge_processor.sweep_to_address }}{% endif %}"
placeholder = "Dogecoin address to sweep funds to (e.g. DQA4...)"
style = "width: 100%;{% if doge_processor and not doge_processor.enabled %} opacity: 0.5; background-color: #f5f5f5;{% endif %}"
{% if doge_processor and not doge_processor.enabled %}readonly{% endif %}
/>
<br/>
<small>Configure your cold wallet address where this shop's Dogecoin funds will be swept. This is required to accept Dogecoin payments.</small>
{% if doge_processor %}
{% if doge_processor.enabled %}
<br />
<br />
<small style="color: green;">✓ Dogecoin wallet configured and ready to accept payments</small>
<br />
<br />
<small>
Your shop can now accept Dogecoin payments! Funds will be automatically swept to:<br/>
<code>{{ doge_processor.sweep_to_address }}</code>
</small>
{% else %}
<br />
<br />
<small style="color: #d44;">✗ Dogecoin payments are currently disabled</small>
<br />
<br />
<small>
Your cold wallet address is preserved but customers cannot select Dogecoin as a payment method.
</small>
{% endif %}
{% else %}
<br />
<br />
<small>Enter your Dogecoin address to start accepting DOGE payments. Such wow! Much payments!</small>
{% endif %}
<br />
<br />
{% if doge_processor %}
{% if doge_processor.enabled %}
<input type="submit" name="submit" class="mps-submit" value="Update Dogecoin Settings" />
<input type="submit" name="disable" class="mps-submit" value="Disable Dogecoin" style="background: #d44; color: white;" />
{% else %}
<input type="submit" name="submit" class="mps-submit" value="Re-enable Dogecoin" style="background: #d4a574;" />
<small style="color: #666;">Enter your cold wallet address to re-enable Dogecoin payments</small>
{% endif %}
{% else %}
<input type="submit" name="submit" class="mps-submit" value="Enable Dogecoin Payments" />
{% endif %}
<br />
<br />
</form>
</section>
</section>
<br />
<br />
{% endif %}
<br />
<br />

View file

@ -28,10 +28,13 @@ from ..lib.crypto_watcher import (
process_payment,
finalize_invoice,
auto_sweep_payment,
ATOMIC_UNITS,
MIN_SWEEP_BALANCE,
auto_sweep_payment_doge,
get_dogecoin_incoming_transfers,
get_coin_config,
get_crypto_client,
COIN_CONFIGS,
)
from ..lib.crypto_clients import MockMoneroClient
from ..lib.crypto_clients import MockMoneroClient, MockDogecoinClient
class CryptoWatcherUnitTests(unittest.TestCase):
@ -613,6 +616,7 @@ class AutoSweepTests(unittest.TestCase):
payment.id = "test_id"
payment.shop_sweep_to_address = None
payment.is_swept = False
payment.coin_type = "XMR"
result = auto_sweep_payment(mock_client, payment)
self.assertFalse(result)
@ -629,6 +633,7 @@ class AutoSweepTests(unittest.TestCase):
payment.account_index = 0
payment.received_amount = 1000000000000 # 1 XMR expected
payment.is_swept = False
payment.coin_type = "XMR"
result = auto_sweep_payment(mock_client, payment)
self.assertTrue(result) # Returns True and marks as swept
@ -655,6 +660,7 @@ class AutoSweepTests(unittest.TestCase):
payment.account_index = 0
payment.received_amount = 500000000000 # 0.5 XMR
payment.is_swept = False
payment.coin_type = "XMR"
result = auto_sweep_payment(mock_client, payment)
self.assertTrue(result)
@ -679,13 +685,12 @@ class AutoSweepTests(unittest.TestCase):
payment = MagicMock()
payment.id = "test_payment_id"
payment.shop_sweep_to_address = "cold_wallet_address"
payment.is_swept = True # Already swept
payment.shop_sweep_to_address = "cold_wallet"
payment.is_swept = True
payment.coin_type = "XMR"
result = auto_sweep_payment(mock_client, payment)
self.assertTrue(result) # Returns True but does nothing
# Should not make any RPC calls
self.assertTrue(result)
mock_client._call.assert_not_called()
def test_auto_sweep_rpc_error(self):
@ -704,5 +709,196 @@ class AutoSweepTests(unittest.TestCase):
self.assertFalse(result)
class DogecoinWatcherUnitTests(unittest.TestCase):
"""Unit tests for Dogecoin crypto_watcher functions."""
def test_get_coin_config_doge(self):
"""Test getting Dogecoin configuration."""
config = get_coin_config("DOGE")
self.assertEqual(
config["atomic_units"], Decimal("100000000")
) # 1 DOGE = 10^8 koinu
self.assertEqual(
config["min_sweep_balance"], Decimal("0.1")
) # Keep 0.1 DOGE for fees
def test_get_coin_config_xmr_fallback(self):
"""Test getting config for unknown coin falls back to XMR."""
config = get_coin_config("UNKNOWN")
self.assertEqual(
config["atomic_units"], Decimal("1000000000000")
) # XMR default
def test_get_crypto_client_doge(self):
"""Test getting Dogecoin client from settings."""
settings = {"dogecoin.mock": "true"}
client = get_crypto_client(settings, "DOGE")
self.assertIsInstance(client, MockDogecoinClient)
def test_get_crypto_client_unsupported(self):
"""Test getting client for unsupported coin type."""
settings = {}
with self.assertRaises(ValueError) as context:
get_crypto_client(settings, "UNSUPPORTED")
self.assertIn("Unsupported coin type", str(context.exception))
def test_get_dogecoin_incoming_transfers_success(self):
"""Test getting Dogecoin incoming transfers successfully."""
mock_client = MagicMock()
mock_client.getreceivedbyaddress.return_value = 10.5
mock_client.listtransactions.return_value = [
{
"address": "DTestAddress123",
"category": "receive",
"amount": 5.0,
"confirmations": 6,
"txid": "tx123",
},
{
"address": "DTestAddress123",
"category": "receive",
"amount": 5.5,
"confirmations": 12,
"txid": "tx456",
},
]
payment = MagicMock()
payment.id = "payment_123"
payment.address = "DTestAddress123"
transfers = get_dogecoin_incoming_transfers(mock_client, payment)
self.assertEqual(len(transfers), 2)
self.assertEqual(transfers[0]["amount"], 500000000) # 5.0 DOGE in koinu
self.assertEqual(transfers[0]["confirmations"], 6)
self.assertEqual(transfers[1]["amount"], 550000000) # 5.5 DOGE in koinu
self.assertEqual(transfers[1]["confirmations"], 12)
def test_get_dogecoin_incoming_transfers_error(self):
"""Test getting Dogecoin transfers with client error."""
mock_client = MagicMock()
mock_client.listtransactions.side_effect = Exception("RPC error")
payment = MagicMock()
payment.id = "payment_123"
payment.address = "DTestAddress123"
transfers = get_dogecoin_incoming_transfers(mock_client, payment)
self.assertEqual(transfers, [])
def test_auto_sweep_payment_doge_success(self):
"""Test successful Dogecoin auto-sweep."""
mock_client = MagicMock()
mock_client.getbalance.return_value = 100.5
mock_client.sendtoaddress.return_value = "sweep_tx_hash_123"
payment = MagicMock()
payment.id = "payment_123"
payment.coin_type = "DOGE"
payment.shop_sweep_to_address = "DColdWalletAddress123"
payment.received_amount = 1000000000 # 10 DOGE in koinu
payment.is_swept = False
payment.invoice.id = "invoice_123"
result = auto_sweep_payment_doge(mock_client, payment)
self.assertTrue(result)
mock_client.sendtoaddress.assert_called_once_with(
"DColdWalletAddress123",
100.4, # 100.5 - 0.1 fee buffer
"Sweep for invoice invoice_123",
)
self.assertEqual(payment.swept_tx_hash, "sweep_tx_hash_123")
def test_auto_sweep_payment_doge_no_address(self):
"""Test Dogecoin auto-sweep with no sweep address."""
mock_client = MagicMock()
payment = MagicMock()
payment.id = "payment_123"
payment.shop_sweep_to_address = None
payment.is_swept = False
result = auto_sweep_payment_doge(mock_client, payment)
self.assertFalse(result)
mock_client.sendtoaddress.assert_not_called()
def test_auto_sweep_payment_doge_already_swept(self):
"""Test Dogecoin auto-sweep when already swept."""
mock_client = MagicMock()
payment = MagicMock()
payment.id = "payment_123"
payment.shop_sweep_to_address = "DColdWalletAddress123"
payment.is_swept = True
result = auto_sweep_payment_doge(mock_client, payment)
self.assertTrue(result)
mock_client.getbalance.assert_not_called()
def test_auto_sweep_payment_doge_low_balance(self):
"""Test Dogecoin auto-sweep with balance too low to sweep."""
mock_client = MagicMock()
mock_client.getbalance.return_value = 0.05 # Below min_sweep_balance of 0.1
payment = MagicMock()
payment.id = "payment_123"
payment.shop_sweep_to_address = "DColdWalletAddress123"
payment.received_amount = 5000000 # 0.05 DOGE in koinu
payment.is_swept = False
result = auto_sweep_payment_doge(mock_client, payment)
self.assertTrue(result) # Marked as swept (pooled sweep)
self.assertEqual(payment.swept_tx_hash, "pooled_sweep")
mock_client.sendtoaddress.assert_not_called()
def test_auto_sweep_payment_dispatcher(self):
"""Test auto_sweep_payment dispatches correctly by coin type."""
mock_client = MagicMock()
# Test DOGE dispatch
doge_payment = MagicMock()
doge_payment.coin_type = "DOGE"
with patch(
"make_post_sell.lib.crypto_watcher.auto_sweep_payment_doge"
) as mock_doge_sweep:
mock_doge_sweep.return_value = True
result = auto_sweep_payment(mock_client, doge_payment)
self.assertTrue(result)
mock_doge_sweep.assert_called_once_with(mock_client, doge_payment)
# Test XMR dispatch
xmr_payment = MagicMock()
xmr_payment.coin_type = "XMR"
with patch(
"make_post_sell.lib.crypto_watcher.auto_sweep_payment_xmr"
) as mock_xmr_sweep:
mock_xmr_sweep.return_value = True
result = auto_sweep_payment(mock_client, xmr_payment)
self.assertTrue(result)
mock_xmr_sweep.assert_called_once_with(mock_client, xmr_payment)
# Test unsupported coin type
unknown_payment = MagicMock()
unknown_payment.coin_type = "UNKNOWN"
result = auto_sweep_payment(mock_client, unknown_payment)
self.assertFalse(result)
if __name__ == "__main__":
unittest.main()

View file

@ -22,6 +22,10 @@ from ..models.stripe_user_shop import StripeUserShop
from ..models.invoice import Invoice, InvoiceLineItem
from ..models.coupon_redemption import CouponRedemption
from ..models.price import Price
from ..models.crypto_payment import CryptoPayment
from ..models.user_crypto_refund_address import UserCryptoRefundAddress
from ..models.shop_location import ShopLocation
import time
class DatabaseIntegrationTests(unittest.TestCase):
@ -1921,3 +1925,526 @@ class TestCryptoPaymentIntegration(DatabaseIntegrationTests):
self.assertFalse(valid_payment.is_expired)
transaction.commit()
class DogecoinPaymentIntegration(DatabaseIntegrationTests):
"""Integration tests for Dogecoin payment functionality."""
def test_create_doge_crypto_payment_for_invoice(self):
"""Test creating a Dogecoin CryptoPayment linked to an Invoice."""
user = get_or_create_user_by_email(self.dbsession, "dogetest@example.com")
shop = Shop(
name="Doge Shop",
phone_number="555-DOGE-555",
billing_address="123 Doge St",
description="Much shop, very payments",
)
product = Product(
title="Test Doge Product", description="Much product, very test"
)
product.shop_id = shop.id
product.price_in_cents = 500 # $5.00
price = Price(product, 500) # $5.00
location = ShopLocation(
shop=shop,
name="Test Location",
address="123 Test St",
city="Test City",
state="TS",
country="Test Country",
postal_code="12345",
)
self.dbsession.add(user)
self.dbsession.add(shop)
self.dbsession.add(product)
self.dbsession.add(price)
self.dbsession.add(location)
self.dbsession.flush()
# Create invoice
invoice = Invoice(user)
invoice.shop = shop
invoice.shop_id = shop.id
invoice.new_line_item(product=product, quantity=1)
self.dbsession.add(invoice)
self.dbsession.flush()
# Create Dogecoin payment
doge_payment = CryptoPayment(
invoice=invoice,
address="DQA4nJFciD9EhtMTZXDJYH6pxTSYrn4wFp", # Example DOGE address
account_index=0, # Not used for DOGE
subaddress_index=0, # Not used for DOGE
coin_type="DOGE",
expected_amount=1500000000, # 15 DOGE in koinu (15 * 10^8)
rate_locked_usd_per_coin=0.33333, # $0.33 per DOGE
quote_expires_at_ms=int((time.time() + 3600) * 1000), # 1 hour
confirmations_required=6,
shop_location=location,
shop_sweep_to_address="DColdWallet123456789",
)
self.dbsession.add(doge_payment)
self.dbsession.flush()
# Verify payment properties
self.assertEqual(doge_payment.coin_type, "DOGE")
self.assertEqual(doge_payment.expected_amount, 1500000000)
self.assertEqual(doge_payment.address, "DQA4nJFciD9EhtMTZXDJYH6pxTSYrn4wFp")
self.assertEqual(doge_payment.shop_sweep_to_address, "DColdWallet123456789")
self.assertEqual(doge_payment.confirmations_required, 6)
self.assertEqual(doge_payment.status, "pending")
self.assertFalse(doge_payment.is_expired)
self.assertEqual(doge_payment.due_amount, 1500000000) # Full amount due
transaction.commit()
def test_doge_payment_with_refund_address_integration(self):
"""Test DOGE payment with user refund address configured."""
user = get_or_create_user_by_email(self.dbsession, "dogerefund@example.com")
shop = Shop(
name="Refund Doge Shop",
phone_number="555-REFUND-55",
billing_address="123 Refund St",
description="Such refunds, much wow",
)
product = Product(
title="Refund Test Product", description="Test refund handling"
)
product.shop_id = shop.id
product.price_in_cents = 1000 # $10.00
price = Price(product, 1000) # $10.00
location = ShopLocation(
shop=shop,
name="Test Location",
address="123 Test St",
city="Test City",
state="TS",
country="Test Country",
postal_code="12345",
)
self.dbsession.add(user)
self.dbsession.add(shop)
self.dbsession.add(product)
self.dbsession.add(price)
self.dbsession.add(location)
self.dbsession.flush()
# Create user DOGE refund address
refund_address = UserCryptoRefundAddress(
user=user,
coin_type="DOGE",
address="DRefund123456789ABCDEF",
label="My Dogecoin Refund Wallet",
)
self.dbsession.add(refund_address)
self.dbsession.flush()
# Create invoice and payment
invoice = Invoice(user)
invoice.shop = shop
invoice.new_line_item(product=product, quantity=1)
self.dbsession.add(invoice)
self.dbsession.flush()
doge_payment = CryptoPayment(
invoice=invoice,
address="DPayment123456789",
account_index=0,
subaddress_index=0,
coin_type="DOGE",
expected_amount=3000000000, # 30 DOGE in koinu
rate_locked_usd_per_coin=0.33333,
quote_expires_at_ms=int((time.time() + 3600) * 1000),
confirmations_required=6,
refund_address=refund_address.address, # Link refund address
)
self.dbsession.add(doge_payment)
self.dbsession.flush()
# Verify refund address is linked
self.assertEqual(doge_payment.refund_address, "DRefund123456789ABCDEF")
self.assertEqual(doge_payment.coin_type, "DOGE")
transaction.commit()
def test_doge_payment_expiry_and_amounts(self):
"""Test Dogecoin payment expiry and amount calculations."""
user = get_or_create_user_by_email(self.dbsession, "dogeexpiry@example.com")
shop = Shop(
name="Expiry Test Shop",
phone_number="555-EXPIRY-55",
billing_address="123 Expiry St",
description="Testing expiry",
)
product = Product(title="Expiry Product", description="Test payment expiry")
product.shop_id = shop.id
product.price_in_cents = 2500 # $25.00
price = Price(product, 2500) # $25.00
location = ShopLocation(
shop=shop,
name="Test Location",
address="123 Test St",
city="Test City",
state="TS",
country="Test Country",
postal_code="12345",
)
self.dbsession.add_all([user, shop, product, price, location])
self.dbsession.flush()
invoice = Invoice(user)
invoice.shop = shop
invoice.new_line_item(product=product, quantity=1)
self.dbsession.add(invoice)
self.dbsession.flush()
# Create expired DOGE payment
expired_payment = CryptoPayment(
invoice=invoice,
address="DExpired123456789",
account_index=0,
subaddress_index=0,
coin_type="DOGE",
expected_amount=7500000000, # 75 DOGE in koinu
rate_locked_usd_per_coin=0.33333,
quote_expires_at_ms=int((time.time() - 300) * 1000), # Expired 5 mins ago
confirmations_required=10,
)
self.dbsession.add(expired_payment)
self.dbsession.flush()
# Test expiry and amounts
self.assertTrue(expired_payment.is_expired)
self.assertEqual(expired_payment.due_amount, 7500000000) # Full amount due
self.assertEqual(expired_payment.received_amount, 0)
self.assertFalse(expired_payment.is_swept)
# Simulate partial payment
expired_payment.received_amount = 5000000000 # 50 DOGE received
self.assertEqual(expired_payment.due_amount, 2500000000) # 25 DOGE still due
transaction.commit()
def test_dogecoin_unique_address_generation_integration(self):
"""Test that Dogecoin generates unique addresses for each payment."""
from ..lib.crypto_clients import MockDogecoinClient
from ..models.crypto_processor import CryptoProcessor
# Create test data
user = get_or_create_user_by_email(self.dbsession, "dogeunique@example.com")
shop = Shop(
name="Unique Address Shop",
phone_number="555-UNIQUE-55",
billing_address="123 Unique St",
description="Testing unique addresses",
)
product = Product(title="Test Product", description="Test product")
product.shop_id = shop.id
product.price_in_cents = 1000 # $10.00
price = Price(product, 1000)
location = ShopLocation(
shop=shop,
name="Test Location",
address="123 Test St",
city="Test City",
state="TS",
country="Test Country",
postal_code="12345",
)
self.dbsession.add_all([user, shop, product, price, location])
self.dbsession.flush()
# Create crypto processor for Dogecoin
processor = CryptoProcessor(
shop_id=shop.id, coin_type="DOGE", sweep_to_address="DColdWallet123456789"
)
processor.enabled = True
processor.wallet_label = "test_wallet"
self.dbsession.add(processor)
self.dbsession.flush()
# Create mock Dogecoin client
mock_client = MockDogecoinClient()
# Simulate multiple invoices
invoices = []
for i in range(3):
invoice = Invoice(user)
invoice.shop = shop
invoice.new_line_item(product=product, quantity=1)
self.dbsession.add(invoice)
invoices.append(invoice)
self.dbsession.flush()
# Generate addresses for each invoice
addresses = []
for i, invoice in enumerate(invoices):
# This mimics the logic in crypto_doge_start view
wallet_label = processor.wallet_label or "0"
address = mock_client.getnewaddress(f"{wallet_label}:{invoice.id}")
addresses.append(address)
# Verify all addresses are unique
self.assertEqual(len(addresses), 3)
self.assertEqual(len(set(addresses)), 3) # All unique
# Verify addresses contain wallet label (MockDogecoinClient truncates to 8 chars)
for i, address in enumerate(addresses):
# MockDogecoinClient includes first 8 chars of label in address
self.assertIn("test_wal", address) # "test_wallet" truncated
transaction.commit()
def test_dogecoin_address_labeling_integration(self):
"""Test that Dogecoin address generation includes proper labeling."""
from ..lib.crypto_clients import MockDogecoinClient
from ..models.crypto_processor import CryptoProcessor
# Create test data
user = get_or_create_user_by_email(self.dbsession, "dogelabel@example.com")
shop = Shop(
name="Label Test Shop",
phone_number="555-LABEL-55",
billing_address="123 Label St",
description="Testing address labeling",
)
product = Product(title="Label Product", description="Test labeling")
product.shop_id = shop.id
product.price_in_cents = 500 # $5.00
price = Price(product, 500)
self.dbsession.add_all([user, shop, product, price])
self.dbsession.flush()
# Create crypto processor with custom wallet label
processor = CryptoProcessor(
shop_id=shop.id, coin_type="DOGE", sweep_to_address="DColdWallet987654321"
)
processor.enabled = True
processor.wallet_label = "shop_wallet_123"
self.dbsession.add(processor)
self.dbsession.flush()
# Create invoice
invoice = Invoice(user)
invoice.shop = shop
invoice.new_line_item(product=product, quantity=1)
self.dbsession.add(invoice)
self.dbsession.flush()
# Create mock client and generate address
mock_client = MockDogecoinClient()
wallet_label = processor.wallet_label or "0"
# Test address generation with labeling
expected_label = f"{wallet_label}:{invoice.id}"
address = mock_client.getnewaddress(expected_label)
# Verify the address contains the label components (MockDogecoinClient truncates to 8 chars)
self.assertIn("shop_wal", address) # "shop_wallet_123" truncated to "shop_wal"
# Test with default wallet label (set to "0")
processor.wallet_label = "0"
self.dbsession.add(processor)
self.dbsession.flush()
# Test default label generation
wallet_label = processor.wallet_label or "0"
default_label = f"{wallet_label}:{invoice.id}"
default_address = mock_client.getnewaddress(default_label)
# Should start with "0:" in the first 8 chars
self.assertIn("0:", default_address)
transaction.commit()
def test_monero_enhanced_shop_labeling_integration(self):
"""Test that Monero subaddresses include shop ID in labels for better tracking."""
from ..lib.crypto_clients import MockMoneroClient
from ..models.crypto_processor import CryptoProcessor
# Create test data for multiple shops
user = get_or_create_user_by_email(
self.dbsession, "monero_shop_label@example.com"
)
# Shop 1
shop1 = Shop(
name="Shop One",
phone_number="555-SHOP-1",
billing_address="123 Shop One St",
description="First test shop",
)
# Shop 2
shop2 = Shop(
name="Shop Two",
phone_number="555-SHOP-2",
billing_address="456 Shop Two Ave",
description="Second test shop",
)
# Products for each shop
product1 = Product(title="Shop 1 Product", description="Product from shop 1")
product1.shop_id = shop1.id
product1.price_in_cents = 1500
price1 = Price(product1, 1500)
product2 = Product(title="Shop 2 Product", description="Product from shop 2")
product2.shop_id = shop2.id
product2.price_in_cents = 2000
price2 = Price(product2, 2000)
self.dbsession.add_all([user, shop1, shop2, product1, product2, price1, price2])
self.dbsession.flush()
# Create crypto processors for both shops
processor1 = CryptoProcessor(
shop_id=shop1.id, coin_type="XMR", sweep_to_address="XmrColdWallet1"
)
processor1.enabled = True
processor1.wallet_label = "0" # Account index
processor2 = CryptoProcessor(
shop_id=shop2.id, coin_type="XMR", sweep_to_address="XmrColdWallet2"
)
processor2.enabled = True
processor2.wallet_label = "1" # Account index
self.dbsession.add_all([processor1, processor2])
self.dbsession.flush()
# Create invoices for each shop
invoice1 = Invoice(user)
invoice1.shop = shop1
invoice1.new_line_item(product=product1, quantity=1)
invoice2 = Invoice(user)
invoice2.shop = shop2
invoice2.new_line_item(product=product2, quantity=1)
self.dbsession.add_all([invoice1, invoice2])
self.dbsession.flush()
# Test the enhanced labeling format that would be used in the actual implementation
# This mimics the logic in crypto_xmr_start view
label1 = f"shop:{shop1.id}:invoice:{invoice1.id}"
label2 = f"shop:{shop2.id}:invoice:{invoice2.id}"
# Verify labels contain shop IDs for easy tracking
self.assertIn(str(shop1.id), label1)
self.assertIn(str(shop2.id), label2)
self.assertIn(str(invoice1.id), label1)
self.assertIn(str(invoice2.id), label2)
# Verify label format is consistent with the new format
self.assertTrue(label1.startswith(f"shop:{shop1.id}:invoice:"))
self.assertTrue(label2.startswith(f"shop:{shop2.id}:invoice:"))
# Verify shop IDs are different
self.assertNotEqual(shop1.id, shop2.id)
# Verify we can extract shop ID from label
def extract_shop_id_from_label(label):
if label.startswith("shop:") and ":invoice:" in label:
return label.split(":")[1]
return None
extracted_shop1_id = extract_shop_id_from_label(label1)
extracted_shop2_id = extract_shop_id_from_label(label2)
self.assertEqual(extracted_shop1_id, str(shop1.id))
self.assertEqual(extracted_shop2_id, str(shop2.id))
transaction.commit()
def test_monero_vs_dogecoin_shop_tracking_equivalence(self):
"""Test that both Monero and Dogecoin have equivalent shop tracking capabilities."""
from ..models.crypto_processor import CryptoProcessor
# Create test data
user = get_or_create_user_by_email(self.dbsession, "equivalence@example.com")
shop = Shop(
name="Multi-Crypto Shop",
phone_number="555-MULTI-CRYPTO",
billing_address="123 Multi St",
description="Shop supporting both XMR and DOGE",
)
product = Product(
title="Multi-Crypto Product", description="Product for both coins"
)
product.shop_id = shop.id
product.price_in_cents = 1000
price = Price(product, 1000)
self.dbsession.add_all([user, shop, product, price])
self.dbsession.flush()
# Create invoice
invoice = Invoice(user)
invoice.shop = shop
invoice.new_line_item(product=product, quantity=1)
self.dbsession.add(invoice)
self.dbsession.flush()
# Test Monero labeling format (enhanced with shop ID)
monero_label = f"shop:{shop.id}:invoice:{invoice.id}"
# Test Dogecoin labeling format (using shop.id as wallet_label)
dogecoin_label = f"{shop.id}:{invoice.id}"
# Both systems should include the shop ID
self.assertIn(str(shop.id), monero_label)
self.assertIn(str(shop.id), dogecoin_label)
# Both systems should include the invoice ID
self.assertIn(str(invoice.id), monero_label)
self.assertIn(str(invoice.id), dogecoin_label)
# Test extraction functions for both systems
def extract_shop_from_monero_label(label):
"""Extract shop ID from Monero label format: shop:UUID:invoice:UUID"""
if label.startswith("shop:") and ":invoice:" in label:
return label.split(":")[1]
return None
def extract_shop_from_dogecoin_label(label):
"""Extract shop ID from Dogecoin label format: UUID:UUID"""
parts = label.split(":")
if len(parts) >= 2:
return parts[0] # First part is shop ID
return None
def extract_invoice_from_monero_label(label):
"""Extract invoice ID from Monero label"""
if ":invoice:" in label:
return label.split(":invoice:")[1]
return None
def extract_invoice_from_dogecoin_label(label):
"""Extract invoice ID from Dogecoin label"""
parts = label.split(":")
if len(parts) >= 2:
return parts[1] # Second part is invoice ID
return None
# Test extraction works for both systems
extracted_monero_shop = extract_shop_from_monero_label(monero_label)
extracted_dogecoin_shop = extract_shop_from_dogecoin_label(dogecoin_label)
extracted_monero_invoice = extract_invoice_from_monero_label(monero_label)
extracted_dogecoin_invoice = extract_invoice_from_dogecoin_label(dogecoin_label)
# Verify both systems can track the same shop and invoice
self.assertEqual(extracted_monero_shop, str(shop.id))
self.assertEqual(extracted_dogecoin_shop, str(shop.id))
self.assertEqual(extracted_monero_invoice, str(invoice.id))
self.assertEqual(extracted_dogecoin_invoice, str(invoice.id))
# Verify both systems provide equivalent shop tracking
self.assertEqual(extracted_monero_shop, extracted_dogecoin_shop)
self.assertEqual(extracted_monero_invoice, extracted_dogecoin_invoice)
transaction.commit()

View file

@ -453,7 +453,11 @@ def cart_checkout(request):
# Check for payment information
# Only force Stripe flow if Stripe is the ONLY enabled payment method
only_stripe_enabled = request.stripe_enabled and not request.monero_enabled
only_stripe_enabled = (
request.stripe_enabled
and not request.monero_enabled
and not request.dogecoin_enabled
)
if cart.requires_payment and only_stripe_enabled and stripe_user_shop is None:
msg = ("Please enter your payment information.", "info")
request.session.flash(msg)
@ -484,6 +488,40 @@ def cart_checkout(request):
)
xmr_processor_enabled = xmr_processor is not None
# Check if shop has enabled DOGE crypto processor
doge_processor_enabled = False
if request.dogecoin_enabled:
from ..models.crypto_processor import CryptoProcessor
doge_processor = (
request.dbsession.query(CryptoProcessor)
.filter(
CryptoProcessor.shop_id == request.shop.id,
CryptoProcessor.coin_type == "DOGE",
CryptoProcessor.enabled == True,
)
.first()
)
doge_processor_enabled = doge_processor is not None
# Check for pending crypto quotes for this user
from ..models.crypto_payment import CryptoPayment
pending_quotes = []
if request.user:
# Look for pending crypto payments for this user's invoices
pending_quotes = (
request.dbsession.query(CryptoPayment)
.join(CryptoPayment.invoice)
.filter(
Invoice.user_id == request.user.id,
CryptoPayment.status.in_(["pending", "received"]),
)
.order_by(CryptoPayment.created_timestamp.desc())
.limit(5) # Show up to 5 most recent pending quotes
.all()
)
msg = ("Please confirm your order.", "info")
request.session.flash(msg)
return {
@ -493,6 +531,9 @@ def cart_checkout(request):
"stripe_enabled": request.stripe_enabled,
"monero_enabled": request.monero_enabled,
"xmr_processor_enabled": xmr_processor_enabled,
"dogecoin_enabled": request.dogecoin_enabled,
"doge_processor_enabled": doge_processor_enabled,
"pending_crypto_quotes": pending_quotes,
}
request.session.flash(msg)

View file

@ -6,7 +6,10 @@ from ..models.cart import get_cart_by_id
from ..models.invoice import Invoice
from ..models.crypto_payment import CryptoPayment
from ..lib.crypto_clients import get_client_from_settings
from ..lib.crypto_clients import (
get_client_from_settings,
get_dogecoin_client_from_settings,
)
from . import (
user_required,
@ -201,7 +204,7 @@ def crypto_xmr_start(request):
account_index = int(processor.wallet_label)
client = get_client_from_settings(settings)
label = f"invoice:{invoice.id}"
label = f"shop:{shop.id}:invoice:{invoice.id}"
address, subaddr_index = client.create_subaddress(
account_index=account_index, label=label
)
@ -244,6 +247,238 @@ def crypto_xmr_start(request):
return HTTPFound(f"/cart/{cart.id}")
@view_config(
route_name="crypto_doge_start",
request_method="POST",
require_csrf=True,
renderer="crypto_checkout.j2",
)
def crypto_doge_start(request):
# Check Dogecoin enabled toggle first (no DB access)
if not request.dogecoin_enabled:
request.session.flash(
("Dogecoin payments are disabled by configuration.", "error")
)
return HTTPFound("/cart")
cart_id = request.params.get("cart_id")
if not cart_id:
request.session.flash(("Missing cart_id.", "error"))
return HTTPFound("/cart")
# First database access - this establishes the transaction
cart = get_cart_by_id(request.dbsession, cart_id)
if cart is None:
request.session.flash(("Invalid cart.", "error"))
return HTTPFound("/cart")
# Now check user authentication (after DB transaction is established)
if not (request.user and request.user.authenticated):
request.session.flash(
("To use Dogecoin checkout, please verify your email.", "info")
)
return HTTPFound(request.route_url("join-or-log-in"))
# Now check if shop is ready for payment (after DB transaction is established)
if not (request.shop and request.shop.is_ready_for_payment(request)):
request.session.flash(
(
"Sorry, this shop is not ready to make sales yet. Please try again later.",
"error",
)
)
from . import get_referer_or_home
return HTTPFound(get_referer_or_home(request))
if request.user.does_not_own_cart(cart):
request.session.flash(("You do not own this cart.", "error"))
return HTTPFound("/cart")
# single-shop constraint for MVP
if len(cart.shop_product_dict.keys()) != 1:
request.session.flash(
("Dogecoin checkout only supports single-shop carts.", "error")
)
return HTTPFound(f"/cart/{cart.id}")
if not cart.requires_payment:
request.session.flash(("No payment required for this order.", "info"))
return HTTPFound(f"/cart/{cart.id}")
# Ensure config exists; if not, provide a helpful message.
settings = request.registry.settings
if not settings.get("dogecoin.rpc_url"):
request.session.flash(
(
"Dogecoin RPC not configured. Set dogecoin.rpc_url in your ini to enable.",
"error",
)
)
return HTTPFound(f"/cart/{cart.id}")
# Build a pending invoice for this single shop (do not unlock or send emails yet)
try:
# Extract the single shop and items
(shop_id, items) = next(iter(cart.shop_product_dict.items()))
shop = cart.shops[shop_id]
invoice = Invoice(request.user)
invoice.shop = shop
invoice.shop_id = shop.id
invoice.handling_option = cart.handling_option
invoice.handling_cost_in_cents = cart.handling_cost_in_cents
if cart.physical_products and request.user.active_address:
invoice.delivery_address = request.user.active_address.data
for product, quantity in items:
invoice.new_line_item(product=product, quantity=quantity)
for coupon in cart.coupons:
invoice.new_coupon_redemption(coupon)
# Persist invoice now so we can link a CryptoPayment to it
request.dbsession.add(invoice)
request.dbsession.flush()
# Fetch USD/DOGE rate (with timeout/retry and sanity checks)
rate_url = settings.get(
"dogecoin.rate_source_url",
"https://api.coingecko.com/api/v3/simple/price?ids=dogecoin&vs_currencies=usd",
)
last_err = None
usd_per_doge = None
for attempt in range(3):
try:
req = urllib.request.Request(
rate_url, headers={"User-Agent": "make-post-sell/1.0"}
)
with urllib.request.urlopen(req, timeout=5) as rate_resp:
rate_data = json.loads(rate_resp.read())
candidate = float(rate_data.get("dogecoin", {}).get("usd"))
# sanity bounds: reject zero/negative/absurd values (DOGE ranges from ~$0.01-$1.00)
if not (0.001 <= candidate <= 100.0):
raise RuntimeError("Out-of-bounds USD/DOGE rate")
usd_per_doge = candidate
break
except Exception as e:
last_err = e
time.sleep(0.5)
if usd_per_doge is None:
raise RuntimeError(f"Failed to fetch USD/DOGE rate: {last_err}")
# Compute koinu (smallest unit) owed with transaction fee buffer
usd_total = float(invoice.total)
doge_amount = usd_total / usd_per_doge
# Add fee buffer (default 0.01 DOGE) to cover transaction costs
fee_buffer_doge = float(settings.get("dogecoin.fee_buffer", "0.01"))
doge_amount_with_fee = doge_amount + fee_buffer_doge
expected_koinu = int(doge_amount_with_fee * 100_000_000) # Convert to koinu
# Quote expiry - use shop-specific setting
shop = invoice.shop
expiry_secs = int(shop.crypto_quote_expiry_seconds)
quote_expires_at_ms = int(time.time() * 1000) + (expiry_secs * 1000)
# Check if invoice contains physical products
has_physical = any(
item.product.is_physical
for item in invoice.line_items
if hasattr(item.product, "is_physical")
)
if has_physical:
# Physical products always require maximum confirmations
confirmations_required = int(
settings.get("dogecoin.confirmations.high", "20")
)
else:
# Digital products: determine confirmations based on amount
total_cents = invoice.total_in_cents
# Use shop-specific risk thresholds
shop = invoice.shop
threshold_mid_cents = shop.payment_risk_threshold_mid_cents
threshold_high_cents = shop.payment_risk_threshold_high_cents
if total_cents < threshold_mid_cents:
confirmations_required = int(
settings.get("dogecoin.confirmations.petty", "2")
)
elif total_cents < threshold_high_cents:
confirmations_required = int(
settings.get("dogecoin.confirmations.mid", "6")
)
else:
confirmations_required = int(
settings.get("dogecoin.confirmations.high", "20")
)
# Get crypto processor configuration for this shop
from ..models.crypto_processor import CryptoProcessor
processor = (
request.dbsession.query(CryptoProcessor)
.filter(
CryptoProcessor.shop_id == shop.id,
CryptoProcessor.coin_type == "DOGE",
CryptoProcessor.enabled == True,
)
.first()
)
if not processor or processor.wallet_label is None:
request.session.flash(
("Shop has not configured Dogecoin payments.", "error")
)
return HTTPFound(f"/cart/{cart.id}")
# For Dogecoin, wallet_label stores a label string for address generation
wallet_label = processor.wallet_label
client = get_dogecoin_client_from_settings(settings)
address = client.getnewaddress(f"{wallet_label}:{invoice.id}")
# Get user's saved refund address for this coin type
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"
)
user_refund_address = (
user_refund_addr_obj.address if user_refund_addr_obj else None
)
# Persist CryptoPayment (using account_index=0, subaddress_index=0 for Bitcoin-like coins)
crypto_payment = CryptoPayment(
invoice=invoice,
address=address,
account_index=0, # Not used for Bitcoin-like coins
subaddress_index=0, # Not used for Bitcoin-like coins
coin_type="DOGE",
expected_amount=expected_koinu,
rate_locked_usd_per_coin=usd_per_doge,
quote_expires_at_ms=quote_expires_at_ms,
confirmations_required=confirmations_required,
shop_location=request.shop_location,
shop_sweep_to_address=processor.sweep_to_address,
refund_address=user_refund_address,
)
request.dbsession.add(crypto_payment)
request.dbsession.flush()
# Redirect to generic quote page with payment UUID
return HTTPFound(
request.route_url("crypto_quote", payment_id=str(crypto_payment.id))
)
except Exception as e:
request.session.flash((f"Failed to start Dogecoin checkout: {e}", "error"))
return HTTPFound(f"/cart/{cart.id}")
@view_config(
route_name="crypto_quote",
renderer="crypto_checkout.j2",
@ -268,6 +503,27 @@ def crypto_quote(request):
request.session.flash(("Payment not found.", "error"))
return HTTPFound("/cart")
# Access control: allow purchaser or shop owners/editors
invoice = crypto_payment.invoice
user_can_access = False
if request.user:
# Allow the purchaser (invoice owner)
if invoice.user_id == request.user.id:
user_can_access = True
# Allow shop owners/editors
elif invoice.shop and (
request.user.can_edit_shop(invoice.shop)
or request.user.can_own_shop(invoice.shop)
):
user_can_access = True
if not user_can_access:
request.session.flash(
("Access denied. This quote is not accessible to you.", "error")
)
return HTTPFound("/")
# Get coin-specific information
coin_type = crypto_payment.coin_type
coin_info = get_coin_info(coin_type)
@ -311,6 +567,8 @@ def crypto_quote(request):
"expires_at": crypto_payment.quote_expires_at,
"payment_id": str(crypto_payment.id),
"status": crypto_payment.status,
"current_confirmations": crypto_payment.current_confirmations or 0,
"confirmations_required": crypto_payment.confirmations_required,
"has_refund_address": bool(user_refund_address),
"refund_address": user_refund_address,
"now": int(time.time() * 1000),
@ -383,3 +641,40 @@ def crypto_xmr_status(request):
return Response(
json.dumps(payload), content_type="application/json", charset="utf-8"
)
@view_config(route_name="crypto_doge_status")
@user_required()
def crypto_doge_status(request):
"""Get Dogecoin payment status - same logic as XMR status."""
payment_id = request.matchdict.get("payment_id")
if not payment_id:
return HTTPBadRequest("missing payment_id")
try:
pid = _uuid.UUID(payment_id)
except Exception:
return HTTPBadRequest("invalid payment_id")
crypto_payment = (
request.dbsession.query(CryptoPayment).filter(CryptoPayment.id == pid).first()
)
if not crypto_payment:
return HTTPBadRequest("payment not found")
payload = {
"payment_id": str(crypto_payment.id),
"status": crypto_payment.status,
"address": crypto_payment.address,
"received_amount": crypto_payment.received_amount,
"expected_amount": crypto_payment.expected_amount,
"confirmations_required": crypto_payment.confirmations_required,
"current_confirmations": crypto_payment.current_confirmations or 0,
"expires_at": crypto_payment.quote_expires_at,
# Add atomic unit names for consistency with template
"received_koinu": crypto_payment.received_amount,
"expected_koinu": crypto_payment.expected_amount,
}
return Response(
json.dumps(payload), content_type="application/json", charset="utf-8"
)

View file

@ -5,6 +5,18 @@ from ..models.crypto_processor import CryptoProcessor
from . import shop_owner_required
def validate_dogecoin_address(address):
"""Basic Dogecoin address validation."""
if not address:
return False
# Dogecoin addresses start with 'D' and are typically 34 characters long
if not address.startswith("D") or len(address) != 34:
return False
# Basic character set validation (base58)
valid_chars = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"
return all(c in valid_chars for c in address)
@view_config(route_name="crypto_processor_settings", request_method="POST")
@shop_owner_required()
def crypto_processor_settings(request):
@ -50,6 +62,11 @@ def crypto_processor_settings(request):
if not is_valid:
request.session.flash((f"Invalid Monero address: {error_msg}", "error"))
return HTTPFound(f"/s/{shop.id}/settings")
elif coin_type == "DOGE":
is_valid = validate_dogecoin_address(sweep_to_address)
if not is_valid:
request.session.flash(("Invalid Dogecoin address format", "error"))
return HTTPFound(f"/s/{shop.id}/settings")
# TODO: Add validation for other coin types
if processor:

View file

@ -822,6 +822,19 @@ def shop_settings(request):
.first()
)
# Get crypto processor for Dogecoin if enabled
doge_processor = None
if request.dogecoin_enabled:
from ..models.crypto_processor import CryptoProcessor
doge_processor = (
request.dbsession.query(CryptoProcessor)
.filter(
CryptoProcessor.shop_id == shop.id, CryptoProcessor.coin_type == "DOGE"
)
.first()
)
return {
"name": shop.name,
"description": shop.description,
@ -844,6 +857,7 @@ def shop_settings(request):
shop.payment_risk_threshold_high_cents
),
"xmr_processor": xmr_processor,
"doge_processor": doge_processor,
"signed_posts": signed_posts,
"get_endpoints": get_endpoints,
}