REX Pay — Developer Guide
This guide covers the internal architecture of REX Pay, key design decisions, and step-by-step recipes for extending the system. It is intended for backend engineers working on the platform.
Table of Contents
- Architecture Overview
- Django Apps
- Services Layer
- Celery Tasks
- Utilities
- Key Design Decisions
- Adding a New Chain
- Testing
- Code Standards
1. Architecture Overview
REX Pay is a Django REST Framework API backed by PostgreSQL and Redis, with Celery workers for asynchronous blockchain monitoring and webhook delivery.
┌────────────────────────────────────────────────────────┐
│ Docker Network │
│ │
Merchant API ────►│ api (Gunicorn) │
Checkout UI ────►│ /v1/payment_intents │
│ /v1/public/checkout │
│ /health/ │
│ │ │
│ ▼ │
│ postgres (PostgreSQL 16) redis (Redis 7) │
│ ▲ ▲ │
│ │ │ │
│ celery-beat (scheduler) │ │
│ │ │ │
│ ├── celery-doge ───── dogecoind (Core node) │
│ ├── celery-ltc ───── litecoind (Core node) │
│ ├── celery-tron ───── TronGrid API (external) │
│ └── celery-webhooks ── Merchant endpoints │
│ │
│ frontend (Next.js checkout) │
│ dashboard (Next.js merchant portal) │
│ website (Marketing site) │
└────────────────────────────────────────────────────────┘
Payment Flow
- Merchant calls
POST /v1/payment_intentswith API key. - API derives a unique payment address from the merchant's xpub (BIP44) and creates a
PaymentIntentrecord. - API returns
checkout_url(e.g.https://checkout.rexpay.co/pay/<client_secret>). - Customer opens checkout URL, sees QR code with the payment address and amount.
- Celery beat triggers
watch_all_chainsevery 10 seconds. - Watcher detects the inbound transaction and transitions the
PaymentIntenttodetected. - Confirmation tracker updates confirmation count every 30 seconds. When the threshold is met, status becomes
confirmed. - Each status transition creates a
PaymentEventwhich triggers webhook delivery.
2. Django Apps
The backend is split into three Django apps located under backend/.
2.1 merchants — Merchant Management
Models:
| Model | Purpose |
|---|---|
Merchant |
Core business entity. UUID primary key, name, email, is_active. |
MerchantProfile |
Branding for hosted checkout (display name, logo URL, brand color). One-to-one with Merchant. |
MerchantDogeConfig |
Per-merchant Dogecoin settlement configuration. |
MerchantLtcConfig |
Per-merchant Litecoin settlement configuration. |
MerchantUsdtTrc20Config |
Per-merchant USDT TRC-20 settlement configuration. |
ApiKey |
API keys (rp_live_xxx / rp_test_xxx). Stores a prefix for lookup and a SHA-256 hash of the full key — the plaintext key is never stored. |
WebhookEndpoint |
Merchant-registered webhook URLs with event filters and signing secrets. |
IdempotencyRecord |
Stores cached responses for idempotent API requests (24-hour TTL). |
Per-chain config models share a common structure:
settlement_mode: 'xpub' | 'address'
xpub_encrypted: EncryptedTextField
derivation_index_cursor: int # atomic BIP44 index
address_encrypted: EncryptedTextField
confirmations_required: int
invoice_expiration_minutes: int
underpayment_threshold_percent: Decimal
Key methods on Merchant:
merchant.get_chain_config('DOGE') # Returns MerchantDogeConfig or raises
merchant.supported_chains # ['DOGE', 'LTC'] — only configured chains
Management commands (in merchants/management/commands/):
python manage.py create_merchant --name "..." --email "..."
python manage.py create_apikey --merchant-email "..." --mode live
2.2 payments — Payment Processing
Models:
| Model | Purpose |
|---|---|
PaymentIntent |
Core payment object. Stripe-compatible lifecycle. |
PaymentAddress |
The chain address assigned to a PaymentIntent. One-to-one. |
ChainTransaction |
On-chain transaction record. FK to PaymentIntent. |
PaymentEvent |
Append-only event log. One event per status transition. |
PaymentIntent status lifecycle:
requires_payment → detected → processing → confirmed (terminal)
↘ ↗
expired / canceled (terminal)
↓
flagged (terminal — reorg detected)
Status constants are on PaymentIntentStatus:
PaymentIntentStatus.ACTIVE # ['requires_payment', 'detected', 'processing']
PaymentIntentStatus.TERMINAL # ['confirmed', 'expired', 'canceled']
Column naming note: PaymentIntent.amount_doge is the database column name preserved for backward compatibility. Python code accesses it via the amount_crypto / amount_crypto_with_salt property aliases to remain chain-agnostic. Do not rename the column.
ChainTransaction table name: The model uses db_table = 'payments_dogetransaction' to maintain the original table name after the class was renamed from DogeTransaction to ChainTransaction. A DogeTransaction = ChainTransaction alias is exported for backward compatibility with any existing code that imports the old name.
Event types (PaymentEventType):
payment_intent.created
payment_intent.detected
payment_intent.processing
payment_intent.confirmed
payment_intent.expired
payment_intent.canceled
payment_intent.flagged
payment_intent.reorged
payment_intent.late_payment
payment_intent.overpaid
Checkout URL: payment_intent.checkout_url returns {PUBLIC_CHECKOUT_BASE_URL}/pay/{client_secret}.
2.3 webhooks — Webhook Delivery
Models:
| Model | Purpose |
|---|---|
WebhookDelivery |
Tracks each delivery attempt for a PaymentEvent to a WebhookEndpoint. |
Delivery lifecycle: pending → success or failed (after max attempts).
Signing format (matching Stripe's approach):
The signed payload is {timestamp}.{json_body}. Merchants verify signatures using verify_webhook_signature() from webhooks/tasks.py.
Retry schedule (8 attempts over ~28 hours with ±25% jitter):
| Attempt | Delay |
|---|---|
| 2 | 30 seconds |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 5 hours |
| 7 | 10 hours |
| 8 | 10 hours |
3. Services Layer
The services/ directory contains shared business logic that is not tied to any single Django app. These are plain Python modules, not Django apps (no models.py, no admin.py).
3.1 services/chain/ — Blockchain Integration
File structure:
services/chain/
├── base.py # ChainProvider ABC + Transaction/BlockInfo dataclasses
├── registry.py # CHAINS dict (ChainInfo), adapter registry
├── manager.py # ChainManager, MultiChainManager, singletons
├── utxo_core.py # UTXOCoreProvider (base for DOGE + LTC)
├── dogecoin_core.py # DogecoinCoreProvider (extends UTXOCoreProvider)
├── litecoin_core.py # LitecoinCoreProvider (extends UTXOCoreProvider)
├── tron_provider.py # TronGridProvider (account-based, no node)
└── explorer.py # SoChainProvider (fallback for DOGE)
ChainProvider interface (base.py):
Every chain provider must implement:
class ChainProvider(ABC):
name: str # e.g. "dogecoin_core"
get_block_height() -> int
get_block_hash(height: int) -> str
get_address_transactions(address, min_confirmations) -> List[Transaction]
get_transaction(txid) -> Transaction
get_transaction_confirmations(txid) -> int
is_healthy() -> bool
import_address(address, label, rescan) -> bool
get_transactions_since_block(block_hash, target_confirmations) -> (List[Transaction], str)
UTXOCoreProvider (utxo_core.py) implements all of the above for any Core node via JSON-RPC. DogecoinCoreProvider and LitecoinCoreProvider are thin subclasses that pass chain-specific settings (RPC URL, wallet name, etc.) to the base.
TronGridProvider (tron_provider.py) implements the same interface without a local node. import_address is a no-op because TronGrid indexes all addresses globally. get_transactions_since_block returns an empty list — TRON uses per-address polling instead of listsinceblock.
ChainManager manages one chain with primary + optional fallback providers:
manager = ChainManager(core_provider=DogecoinCoreProvider(), fallback_enabled=False)
provider = manager.get_provider() # Automatically falls back if core is unhealthy
manager.is_degraded() # True if currently using fallback
manager.get_status() # Dict with health info
MultiChainManager provides a single interface across all chains:
from services.chain.manager import get_multi_chain_manager
mcm = get_multi_chain_manager() # Singleton
provider = mcm.get_provider('DOGE') # Returns active provider for DOGE
manager = mcm.get_manager('LTC') # Returns ChainManager for LTC
mcm.get_status() # Status of all initialized chains
ChainInfo (registry.py) is a dataclass with chain metadata:
ChainInfo(
code='DOGE',
name='Dogecoin',
symbol='DOGE',
coin_type=3, # BIP44 coin type
decimals=8,
coingecko_id='dogecoin',
address_prefixes={'mainnet': ['D'], 'testnet': ['n']},
explorer_urls={...},
uri_scheme='dogecoin', # For QR code URI generation
chain_type='utxo', # 'utxo' or 'account'
is_token=False,
default_confirmations=1,
)
3.2 services/address.py — Address Derivation
Uses bip-utils to derive addresses from merchant xpubs via BIP44 path m/44'/{coin_type}'/0'/0/{index}.
BIP44 coin type mapping:
| Chain | Mainnet | Testnet |
|---|---|---|
| DOGE | Bip44Coins.DOGECOIN |
Bip44Coins.DOGECOIN_TESTNET |
| LTC | Bip44Coins.LITECOIN |
Bip44Coins.LITECOIN_TESTNET |
| USDT_TRC20 | Bip44Coins.TRON |
Bip44Coins.TRON |
Core functions:
# Generate and persist a unique payment address
address, derivation_index = generate_address_for_payment_intent(
chain_config, # MerchantDogeConfig / MerchantLtcConfig / etc.
payment_intent,
chain_code='DOGE'
)
# Validate an address for a specific chain and network
is_valid = validate_address_for_chain('D8vFezXh...', 'DOGE', 'mainnet')
# Validate an xpub for a specific chain
is_valid = validate_xpub_for_chain('dgub8...', 'DOGE', 'mainnet')
XPUB mode vs. single address mode:
xpubmode: A new address is derived for eachPaymentIntentby atomically incrementingderivation_index_cursorin the database (SELECT FOR UPDATE). This is the recommended mode.addressmode: The same address is reused for all payments. To distinguish payments, an amount salt is applied (a small, unique offset added to the expected amount).
3.3 services/rates.py — Exchange Rate Service
Fetches fiat/crypto rates from CoinGecko and caches them in Redis.
from services.rates import get_rate_service
svc = get_rate_service() # Singleton
# Get rate for any chain
rate = svc.get_crypto_rate('DOGE', 'USD') # RateResult(rate=Decimal('0.098'), ...)
# Convert fiat to crypto
result = svc.convert_fiat_to_crypto(Decimal('50.00'), 'USD', 'DOGE')
# result.amount_crypto, result.rate, result.rate_source
USDT special case: USDT/USD always returns a fixed 1:1 rate without making an API call.
Supported fiat currencies: USD, EUR, UAH, PLN, GBP (via CoinGecko IDs).
Stale cache fallback: If CoinGecko is unavailable, a 24-hour stale value is returned rather than failing the request.
3.4 services/encryption.py — Fernet Encryption
All sensitive merchant data (xpubs, addresses, webhook secrets) is stored encrypted in the database using EncryptedTextField.
from services.encryption import encrypt, decrypt, EncryptedTextField
# Manual encrypt/decrypt
ciphertext = encrypt("dgub8...my_xpub")
plaintext = decrypt(ciphertext)
# Django model field — transparent encrypt on save, decrypt on load
class MerchantDogeConfig(models.Model):
xpub_encrypted = EncryptedTextField(blank=True)
EncryptedTextField.get_prep_value() is idempotent — it will not double-encrypt a value that is already encrypted.
4. Celery Tasks
Tasks are split across two directories: tasks/ (chain-related) and webhooks/ (delivery).
4.1 tasks/watcher.py — Chain Watcher
watch_all_chains() (beat: every 10s)
Queries all PaymentIntent records in ACTIVE statuses, groups them by chain_code, and calls watch_chain_for(chain_code) for each active chain.
watch_chain_for(chain_code)
Dispatches to one of two strategies based on chain_info.chain_type:
- UTXO chains (DOGE, LTC): Calls
get_transactions_since_block()which uses the Core node'slistsinceblockRPC call to efficiently get all wallet transactions since the last processed block. The last block hash is cached in Redis per chain. - Account-based chains (USDT_TRC20): Calls
get_address_transactions()per active address. TronGrid is polled individually for each address.
process_transaction(tx, payment_intent, chain_code)
Atomic function that creates or updates a ChainTransaction record, sums total received, and calls determine_status() to drive status transitions.
determine_status(pi, total_received)
Considers:
- Transaction confirmation count (minimum across all transactions)
- underpayment_threshold_percent from the merchant's chain-specific config
- confirmations_required on the PaymentIntent
Returns the correct PaymentIntentStatus value.
4.2 tasks/confirmations.py — Confirmation Tracker
update_confirmations() (beat: every 30s)
Fetches all PaymentIntent records in DETECTED or PROCESSING status, groups by chain, and calls _update_chain_confirmations() per chain.
For UTXO chains, uses provider.get_transaction(txid) to get the current confirmation count.
For account-based chains (USDT_TRC20), uses the dedicated provider.get_transaction_confirmations(txid) method.
Reorg detection: If confirmations decrease on a previously-seen transaction, handle_reorg() is called. If a confirmed payment is affected, the PaymentIntent is flagged and a payment_intent.flagged event is created for manual review.
4.3 tasks/expiry.py — Expiry Handler
check_expired() (beat: every 60s)
Finds all PaymentIntent records with status=requires_payment and expires_at < now. For each, transitions to expired, creates a payment_intent.expired event, and queues webhook delivery.
cleanup_old_intents() (beat: Sundays at 3:00 AM)
Logs old terminal-state intents for potential archival. Does not delete data. Extend this function to implement archival to cold storage.
4.4 webhooks/tasks.py — Webhook Delivery
deliver_webhook(delivery_id)
Fetches the WebhookDelivery, builds the JSON payload, computes the HMAC-SHA256 signature, and POSTs to the merchant's endpoint.
Headers sent:
Content-Type: application/json
X-Webhook-Signature: t=<timestamp>,v1=<signature>
X-Webhook-ID: <delivery_uuid>
X-Event-ID: <evt_xxx>
X-Event-Type: payment_intent.confirmed
retry_failed_webhooks() (beat: every 60s)
Backup retry sweep that picks up any WebhookDelivery records past their next_retry_at time. This handles cases where the scheduled apply_async countdown did not fire.
5. Utilities
5.1 utils/auth.py — API Key Authentication
ApiKeyAuthentication is a DRF BaseAuthentication subclass. It reads the Authorization: Bearer rp_live_xxx header, looks up the key by SHA-256 hash, and sets request.auth = api_key_instance.
Helper functions:
from utils.auth import get_merchant_from_request, get_api_key_from_request, is_test_mode
merchant = get_merchant_from_request(request) # Returns Merchant or None
api_key = get_api_key_from_request(request) # Returns ApiKey or None
in_test = is_test_mode(request) # True if rp_test_xxx key
Permission classes:
5.2 utils/rate_limiting.py — Rate Limiting
Redis-based sliding window rate limiter. Implemented as two DRF view mixins:
class MyView(RateLimitMixin, APIView):
def post(self, request):
error = self.check_rate_limits(request)
if error:
return error
...
class MyCheckoutView(CheckoutRateLimitMixin, APIView):
def get(self, request, client_secret):
error = self.check_checkout_rate_limit(request, client_secret)
if error:
return error
...
Rate limits are pulled from settings:
| Key type | Setting | Default |
|---|---|---|
| Live API key | RATE_LIMIT_LIVE_PER_MINUTE |
60 req/min |
| Test API key | RATE_LIMIT_TEST_PER_MINUTE |
120 req/min |
| Checkout (unauthenticated) | RATE_LIMIT_CHECKOUT_PER_MINUTE |
30 req/min |
Rate limit headers are added to responses via add_rate_limit_headers(response, request).
5.3 utils/idempotency.py — Idempotency
IdempotencyMixin for DRF views:
class CreatePaymentIntentView(IdempotencyMixin, APIView):
def post(self, request):
cached_response, idempotency_key = self.check_idempotency(request)
if cached_response:
return cached_response
...
self.store_idempotent_response(request, idempotency_key, response_data, 201)
The mixin reads the Idempotency-Key request header and stores the response in IdempotencyRecord for 24 hours (configurable via IDEMPOTENCY_KEY_TTL_HOURS). The key is scoped per merchant (hashed as sha256(merchant_id:idempotency_key)).
5.4 utils/id_generator.py — ID Generation
Generates Stripe-style prefixed IDs using secrets.choice over a safe alphabet ([a-z0-9]):
| Function | Example output | Usage |
|---|---|---|
generate_payment_intent_id() |
pi_abc123def456... |
PaymentIntent.public_id |
generate_client_secret(pi_id) |
pi_xxx_secret_yyy |
PaymentIntent.client_secret |
generate_event_id() |
evt_abc123def456... |
PaymentEvent.event_id |
generate_api_key(mode) |
rp_live_xxx... |
ApiKey (full key + prefix) |
generate_webhook_secret() |
whsec_xxx... |
WebhookEndpoint.secret_encrypted |
API keys are never stored in plaintext. Only the prefix (rp_live_xxxxxxxx) and a SHA-256 hash of the full key are stored.
6. Key Design Decisions
6.1 DB Column Naming — Keep amount_doge, Use Python Aliases
The PaymentIntent model was originally DOGE-only. When multi-chain support was added, the database columns (amount_doge, amount_doge_with_salt) were preserved to avoid a migration that drops and recreates columns (which would cause downtime on a populated table).
Chain-agnostic Python code accesses amounts via properties:
payment_intent.amount_crypto # Reads from amount_doge
payment_intent.amount_crypto_with_salt # Reads from amount_doge_with_salt
If new code only accesses these through the property aliases, it will work correctly for all chains. Do not add new chain-specific amount columns — the chain_code field on the model identifies the chain.
6.2 ChainTransaction Rename Strategy — Code Rename + db_table Override
The model class was renamed from DogeTransaction to ChainTransaction to reflect multi-chain use, but the database table name is preserved via:
A module-level alias is exported for any import paths that still use the old name:
This approach avoids any migration impact. Do not remove the db_table override or the alias until all import references have been audited.
6.3 Per-Chain Config Models — Not Polymorphic
Each chain has its own concrete config model (MerchantDogeConfig, MerchantLtcConfig, MerchantUsdtTrc20Config) rather than a single polymorphic model. This was chosen because:
- Each chain has chain-specific fields (e.g.,
salt_unit_dogevs.salt_unit— a naming inconsistency that exists in the current code). - Django admin and migrations are simpler with concrete models.
- Type-safe access without casting.
The tradeoff is that adding a new chain requires a new model class and migration. The Merchant.get_chain_config(chain_code) method provides a unified lookup:
config = merchant.get_chain_config('DOGE') # Returns MerchantDogeConfig
config = merchant.get_chain_config('LTC') # Returns MerchantLtcConfig
6.4 MultiChainManager Pattern
Chain providers are not instantiated directly in views or tasks. All access goes through the MultiChainManager singleton:
from services.chain.manager import get_multi_chain_manager
mcm = get_multi_chain_manager()
provider = mcm.get_provider('LTC')
The MultiChainManager._create_manager(chain_code) method is the single place where provider classes are instantiated. When adding a new chain, add a new elif chain_code == 'XYZ': block there.
6.5 Per-Chain Celery Queues
Each chain has a dedicated Celery queue and worker. This enables:
- Independent scaling per chain based on volume.
- Isolation: a crash in the DOGE worker does not affect LTC or webhook delivery.
- Clean separation for monitoring and alerting.
Queue definitions are in core/celery.py:
app.conf.task_queues = (
Queue('celery', routing_key='celery'), # Default
Queue('doge', routing_key='doge'),
Queue('ltc', routing_key='ltc'),
Queue('usdt_trc20', routing_key='usdt_trc20'),
Queue('webhooks', routing_key='webhooks'),
)
Task routing rules map task name patterns to queues. For new chains, add a new queue, update the routing rules, and add a new worker service to docker-compose.yml.
6.6 TronGrid API vs. Running a TRON Node
TRON nodes require significant resources (hundreds of GB, powerful hardware). For a payment processor that only needs to watch for incoming transfers, running a full TRON node is disproportionate. TronGrid's free API tier provides sufficient throughput for monitoring USDT TRC-20 transfers.
The TronGridProvider.import_address() method is a no-op because TronGrid indexes all addresses globally — there is no concept of a watch-only wallet to import into.
7. Adding a New Chain
This section is a step-by-step recipe for adding support for a new cryptocurrency. The example uses a hypothetical chain "XYZ".
Step 1: Add to registry.py
Add a ChainInfo entry to the CHAINS dict in backend/services/chain/registry.py:
'XYZ': ChainInfo(
code='XYZ',
name='XYZcoin',
symbol='XYZ',
coin_type=999, # BIP44 coin type from SLIP-0044
decimals=8,
coingecko_id='xyzcoin', # CoinGecko coin ID
address_prefixes={
'mainnet': ['X'],
'testnet': ['x'],
},
explorer_urls={
'mainnet': 'https://explorer.xyzcoin.org',
'testnet': 'https://testnet.explorer.xyzcoin.org',
},
uri_scheme='xyzcoin', # For QR code payment URIs; None if no standard
chain_type='utxo', # 'utxo' or 'account'
is_token=False,
default_confirmations=3,
),
Step 2: Create the Provider
For a UTXO chain with a Core-compatible node, extend UTXOCoreProvider:
# backend/services/chain/xyz_core.py
from django.conf import settings
from .utxo_core import UTXOCoreProvider
class XYZCoreProvider(UTXOCoreProvider):
def __init__(self):
super().__init__(
url=settings.XYZ_RPC_URL,
user=settings.XYZ_RPC_USER,
password=settings.XYZ_RPC_PASSWORD,
timeout=float(settings.XYZ_RPC_TIMEOUT),
wallet=settings.XYZ_RPC_WALLET,
)
@property
def name(self) -> str:
return "xyz_core"
For an account-based chain or API-only provider, extend ChainProvider directly (see tron_provider.py as a reference).
Step 3: Add the Merchant Config Model
Add a new model to backend/merchants/models.py following the pattern of MerchantLtcConfig. Copy the full structure including EncryptedTextField, derivation_index_cursor, and the xpub / address property pairs.
class MerchantXyzConfig(models.Model):
merchant = models.OneToOneField(Merchant, on_delete=models.CASCADE, related_name='xyz_config')
network = models.CharField(max_length=10, choices=[('mainnet', 'Mainnet'), ('testnet', 'Testnet')], default='testnet')
settlement_mode = models.CharField(max_length=10, choices=[('xpub', 'XPUB'), ('address', 'Single Address')], default='xpub')
xpub_encrypted = EncryptedTextField(blank=True)
derivation_index_cursor = models.PositiveIntegerField(default=0)
address_encrypted = EncryptedTextField(blank=True)
confirmations_required = models.PositiveSmallIntegerField(default=3)
invoice_expiration_minutes = models.PositiveSmallIntegerField(default=30)
underpayment_threshold_percent = models.DecimalField(max_digits=5, decimal_places=2, default=Decimal('1.0'))
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
@property
def xpub(self): return self.xpub_encrypted or ''
@xpub.setter
def xpub(self, value): self.xpub_encrypted = value
@property
def address(self): return self.address_encrypted or ''
@address.setter
def address(self, value): self.address_encrypted = value
Then register it in Merchant.get_chain_config() and Merchant.supported_chains:
# In Merchant.get_chain_config():
config_map = {
'DOGE': 'doge_config',
'LTC': 'ltc_config',
'USDT_TRC20': 'usdt_trc20_config',
'XYZ': 'xyz_config', # Add this
}
# In Merchant.supported_chains:
for chain_code, related_name in [
('DOGE', 'doge_config'),
('LTC', 'ltc_config'),
('USDT_TRC20', 'usdt_trc20_config'),
('XYZ', 'xyz_config'), # Add this
]:
Step 4: Add BIP44 Coin to address.py
Add the BIP44 coin type mapping to CHAIN_BIP44_COINS in backend/services/address.py:
CHAIN_BIP44_COINS = {
'DOGE': {...},
'LTC': {...},
'USDT_TRC20': {...},
'XYZ': {
'mainnet': Bip44Coins.XYZ, # Use the correct Bip44Coins enum
'testnet': Bip44Coins.XYZ_TESTNET,
},
}
If bip-utils does not support XYZ natively, you will need to implement custom address derivation and skip this step.
Step 5: Add to MultiChainManager
Add the chain to MultiChainManager._create_manager() in backend/services/chain/manager.py:
elif chain_code == 'XYZ':
from .xyz_core import XYZCoreProvider
return ChainManager(
core_provider=XYZCoreProvider(),
fallback_enabled=False,
)
Step 6: Add Settings
Add the RPC configuration variables to backend/core/settings.py:
XYZ_RPC_HOST = os.getenv('XYZ_RPC_HOST', 'xyzd')
XYZ_RPC_PORT = os.getenv('XYZ_RPC_PORT', '12345')
XYZ_RPC_URL = os.getenv('XYZ_RPC_URL', f'http://{XYZ_RPC_HOST}:{XYZ_RPC_PORT}')
XYZ_RPC_USER = os.getenv('XYZ_RPC_USER', 'rpcuser')
XYZ_RPC_PASSWORD = os.getenv('XYZ_RPC_PASSWORD', 'rpcpassword')
XYZ_RPC_TIMEOUT = float(os.getenv('XYZ_RPC_TIMEOUT', '30.0'))
XYZ_RPC_WALLET = os.getenv('XYZ_RPC_WALLET', 'regenpay_watch')
XYZ_NETWORK = os.getenv('XYZ_NETWORK', 'testnet')
Step 7: Add Celery Queue and Docker Worker
In backend/core/celery.py, add the queue:
In docker-compose.yml, add the node service and worker:
xyzd:
image: xyz/xyz-core:latest
command: >
xyzd -testnet -server -rpcuser=regenpay -rpcpassword=regenpay
-rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0 -rpcport=12345 -txindex=1
ports:
- "12345:12345"
volumes:
- xyz_data:/home/xyz/.xyz
restart: unless-stopped
celery-xyz:
build:
context: ./backend
dockerfile: Dockerfile
env_file: .env
environment:
- DATABASE_URL=postgres://regenpay:regenpay@postgres:5432/regenpay
- REDIS_URL=redis://redis:6379/0
- CELERY_BROKER_URL=redis://redis:6379/0
- CELERY_RESULT_BACKEND=redis://redis:6379/0
depends_on:
- postgres
- redis
- xyzd
command: celery -A core worker -Q xyz -l info --concurrency=2
restart: unless-stopped
Add the volume at the bottom of docker-compose.yml:
Step 8: Add Rate Service Support (if new coin)
Add the CoinGecko ID to RateService.COINGECKO_IDS in backend/services/rates.py:
COINGECKO_IDS = {
'DOGE': 'dogecoin',
'LTC': 'litecoin',
'USDT_TRC20': 'tether',
'XYZ': 'xyzcoin', # Add this
}
Also add the precision constant:
XYZ_PRECISION = Decimal('0.00000001')
CHAIN_PRECISION = {
'DOGE': DOGE_PRECISION,
'LTC': LTC_PRECISION,
'USDT_TRC20': USDT_PRECISION,
'XYZ': XYZ_PRECISION,
}
Step 9: Run Migrations
docker compose exec api python manage.py makemigrations merchants
docker compose exec api python manage.py migrate
Step 10: Update the Frontend (if needed)
For UTXO chains with a standard URI scheme, the checkout frontend QR code generator uses the uri_scheme field from ChainInfo to construct payment URIs:
If the new chain uses a non-standard scheme or no scheme (like USDT TRC-20), update the frontend QR code component accordingly.
8. Testing
Running Tests
# In the backend container
docker compose exec api python manage.py test
# With coverage
docker compose exec api coverage run manage.py test
docker compose exec api coverage report
Test Mode vs. Live Mode
API keys are explicitly scoped to test or live mode. Test mode keys have higher rate limits (120/min vs. 60/min). The key mode is checked via:
In test mode, transactions are on testnet chains. A merchant can have separate chain configs for testnet (test keys) and mainnet (live keys).
Mocking Chain Providers
For unit tests, mock the ChainProvider interface rather than hitting real nodes:
from unittest.mock import MagicMock, patch
from services.chain.base import Transaction
mock_provider = MagicMock()
mock_provider.get_block_height.return_value = 5000000
mock_provider.get_transactions_since_block.return_value = (
[Transaction(txid='abc123', address='D...', amount=Decimal('100'), confirmations=1)],
'blockhash_abc'
)
with patch('services.chain.manager.get_multi_chain_manager') as mock_mcm:
mock_mcm.return_value.get_provider.return_value = mock_provider
# ... run task
Seeding Test Data
from merchants.models import Merchant, ApiKey, MerchantDogeConfig
from decimal import Decimal
merchant = Merchant.objects.create(name="Test Merchant", email="test@example.com")
MerchantDogeConfig.objects.create(
merchant=merchant,
network='testnet',
settlement_mode='address',
address_encrypted='nXxxTestAddressXxx', # Will be encrypted on save
confirmations_required=1,
invoice_expiration_minutes=30,
underpayment_threshold_percent=Decimal('1.0'),
)
api_key, full_key = ApiKey.create_key(merchant, mode='test', name='Dev key')
print(f"API Key: {full_key}")
9. Code Standards
Import Order
Follow the standard Python / Django convention:
# 1. Standard library
import os
from decimal import Decimal
# 2. Third-party
import httpx
from celery import shared_task
# 3. Django
from django.conf import settings
from django.db import models
# 4. DRF
from rest_framework import status
from rest_framework.views import APIView
# 5. Local — apps
from merchants.models import Merchant
from payments.models import PaymentIntent
# 6. Local — services/utils
from services.chain.manager import get_multi_chain_manager
from utils.auth import get_merchant_from_request
Chain-Agnostic Code
- Access amounts via
amount_crypto/amount_crypto_with_saltproperties, notamount_dogedirectly. - Use
chain_info.symbolfor display labels (e.g. "DOGE", "LTC", "USDT"). - Use
chain_info.chain_typeto branch between UTXO and account-based logic. - Use
get_chain_info(chain_code)fromservices.chain.registryrather than hardcoding chain metadata.
Atomic Operations
Index increments and status transitions must be atomic:
# Correct — atomic index increment
with transaction.atomic():
config = MerchantDogeConfig.objects.select_for_update().get(pk=config.pk)
index = config.derivation_index_cursor
config.derivation_index_cursor += 1
config.save(update_fields=['derivation_index_cursor', 'updated_at'])
# Correct — status transition
with transaction.atomic():
pi = PaymentIntent.objects.select_for_update().get(pk=pi.pk)
if pi.status not in PaymentIntentStatus.ACTIVE:
return
pi.status = PaymentIntentStatus.CONFIRMED
pi.save(update_fields=['status', 'updated_at'])
Encrypted Fields
Always access sensitive fields through the defined Python properties (xpub, address), not the raw _encrypted field. The EncryptedTextField handles encryption and decryption automatically via from_db_value and get_prep_value. Do not call encrypt() or decrypt() manually on fields managed by EncryptedTextField.
Error Handling in Tasks
Celery tasks should:
- Log errors with logger.exception() to include the full traceback.
- Re-raise exceptions so Celery can track task failures.
- Never silently swallow exceptions that indicate a provider outage.
- Gracefully skip individual items that fail without aborting the entire batch.
for chain_code in chain_codes:
try:
process_chain(chain_code)
except Exception as e:
logger.exception(f"[{chain_code}] Error: {e}")
# Do not re-raise here — continue to next chain
Provider Selection
Never instantiate a ChainProvider directly in views or tasks. Always go through the manager singleton: