diff --git a/CLAUDE.md b/CLAUDE.md
index e837371..208ca6b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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.
\ No newline at end of file
+**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.
\ No newline at end of file
diff --git a/Makefile b/Makefile
index 3b0d844..ae25bd8 100644
--- a/Makefile
+++ b/Makefile
@@ -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
# -----------------------------------------------------------------------------
diff --git a/development.ini b/development.ini
index 8bd2946..8ff1aa7 100644
--- a/development.ini
+++ b/development.ini
@@ -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/
diff --git a/docs/DOGECOIN.md b/docs/DOGECOIN.md
new file mode 100644
index 0000000..47ea54f
--- /dev/null
+++ b/docs/DOGECOIN.md
@@ -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!
diff --git a/MONERO.rst b/docs/MONERO.rst
similarity index 100%
rename from MONERO.rst
rename to docs/MONERO.rst
diff --git a/make_post_sell/lib/crypto_clients.py b/make_post_sell/lib/crypto_clients.py
index 2a733a3..0978f77 100644
--- a/make_post_sell/lib/crypto_clients.py
+++ b/make_post_sell/lib/crypto_clients.py
@@ -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"),
+ )
diff --git a/make_post_sell/lib/crypto_watcher.py b/make_post_sell/lib/crypto_watcher.py
index d986cb3..c2de220 100644
--- a/make_post_sell/lib/crypto_watcher.py
+++ b/make_post_sell/lib/crypto_watcher.py
@@ -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):
diff --git a/make_post_sell/models/crypto_payment.py b/make_post_sell/models/crypto_payment.py
index 2c7d28f..f58fbc0 100644
--- a/make_post_sell/models/crypto_payment.py
+++ b/make_post_sell/models/crypto_payment.py
@@ -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
diff --git a/make_post_sell/models/shop.py b/make_post_sell/models/shop.py
index 2959342..1f0b014 100644
--- a/make_post_sell/models/shop.py
+++ b/make_post_sell/models/shop.py
@@ -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
diff --git a/make_post_sell/request_methods.py b/make_post_sell/request_methods.py
index 85a56e1..685c6b6 100644
--- a/make_post_sell/request_methods.py
+++ b/make_post_sell/request_methods.py
@@ -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
+ )
diff --git a/make_post_sell/routes.py b/make_post_sell/routes.py
index 721eefb..4f934a8 100644
--- a/make_post_sell/routes.py
+++ b/make_post_sell/routes.py
@@ -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}")
diff --git a/make_post_sell/templates/cart_checkout.j2 b/make_post_sell/templates/cart_checkout.j2
index 78c3b35..49be883 100644
--- a/make_post_sell/templates/cart_checkout.j2
+++ b/make_post_sell/templates/cart_checkout.j2
@@ -63,6 +63,35 @@
{% endif %}
+ {# Show pending crypto quotes if user has any #}
+ {% if pending_crypto_quotes %}
+
+
📋 Your Pending Crypto Quotes
+
You have {{ pending_crypto_quotes|length }} pending crypto payment{{ 's' if pending_crypto_quotes|length != 1 else '' }}:
+ {% for quote in pending_crypto_quotes %}
+
+
+
+ {{ quote.coin_type }} Payment
+
+ Status: {{ quote.status.title() }}
+ {% if quote.current_confirmations > 0 %}
+ ({{ quote.confirmation_status }})
+ {% endif %}
+
+
+
View Quote
+
+
+ Amount: {{ '%.8f' % (quote.expected_amount / (1000000000000 if quote.coin_type == 'XMR' else 100000000)) }} {{ quote.coin_type }}
+
+
+ {% endfor %}
+
+ 💡 Complete an existing payment or create a new quote below.
+
+
+ {% 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 %}
+
+
+ {% if not request.has_doge_refund_address %}
+
+ ⚠️ Configure a DOGE refund address to enable automatic refunds during payment errors.
+
+ {% endif %}
+ {% endif %}
+
+ {% if not stripe_enabled and not (monero_enabled and xmr_processor_enabled) and not (dogecoin_enabled and doge_processor_enabled) %}
No payment methods are enabled. Please contact the shop owner.
{% endif %}
diff --git a/make_post_sell/templates/crypto_checkout.j2 b/make_post_sell/templates/crypto_checkout.j2
index cf0899d..4da6c89 100644
--- a/make_post_sell/templates/crypto_checkout.j2
+++ b/make_post_sell/templates/crypto_checkout.j2
@@ -21,8 +21,8 @@
Status: {{ status }}
-
- Confirmations: 0 / 0
+
+ Confirmations: {{ current_confirmations or 0 }} / {{ confirmations_required or 0 }}
{% if expires_at %}
diff --git a/make_post_sell/templates/invoice.j2 b/make_post_sell/templates/invoice.j2
index 6a999cb..1fd9ebd 100644
--- a/make_post_sell/templates/invoice.j2
+++ b/make_post_sell/templates/invoice.j2
@@ -32,6 +32,12 @@
{% if crypto_pay.tx_hashes and crypto_pay.tx_hashes != '[]' %}
Transaction(s): {{ crypto_pay.tx_hashes }}
{% endif %}
+
+
+
+ 📊 View Live Quote & Payment Status
+
+
{% endif %}
diff --git a/make_post_sell/templates/shop_settings.j2 b/make_post_sell/templates/shop_settings.j2
index 067a9fd..027115d 100644
--- a/make_post_sell/templates/shop_settings.j2
+++ b/make_post_sell/templates/shop_settings.j2
@@ -322,6 +322,82 @@
{% endif %}
+{% if request.dogecoin_enabled %}
+
+
+
+ Dogecoin (DOGE) Configuration 🐕
+
+
+
+
+
+
+
+
+{% endif %}
+
diff --git a/make_post_sell/tests/test_crypto_watcher.py b/make_post_sell/tests/test_crypto_watcher.py
index dea6054..c99a7a6 100644
--- a/make_post_sell/tests/test_crypto_watcher.py
+++ b/make_post_sell/tests/test_crypto_watcher.py
@@ -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()
diff --git a/make_post_sell/tests/test_integration.py b/make_post_sell/tests/test_integration.py
index 4e641bd..96f33f8 100644
--- a/make_post_sell/tests/test_integration.py
+++ b/make_post_sell/tests/test_integration.py
@@ -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()
diff --git a/make_post_sell/views/cart.py b/make_post_sell/views/cart.py
index 74f37ac..3426cbe 100644
--- a/make_post_sell/views/cart.py
+++ b/make_post_sell/views/cart.py
@@ -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)
diff --git a/make_post_sell/views/crypto.py b/make_post_sell/views/crypto.py
index a81a4a2..4867fcf 100644
--- a/make_post_sell/views/crypto.py
+++ b/make_post_sell/views/crypto.py
@@ -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"
+ )
diff --git a/make_post_sell/views/crypto_processor.py b/make_post_sell/views/crypto_processor.py
index 03c8524..b35c9b2 100644
--- a/make_post_sell/views/crypto_processor.py
+++ b/make_post_sell/views/crypto_processor.py
@@ -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:
diff --git a/make_post_sell/views/shop.py b/make_post_sell/views/shop.py
index 99ec8a1..67aa9f0 100644
--- a/make_post_sell/views/shop.py
+++ b/make_post_sell/views/shop.py
@@ -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,
}