Skip to content

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

  1. Architecture Overview
  2. Django Apps
  3. Services Layer
  4. Celery Tasks
  5. Utilities
  6. Key Design Decisions
  7. Adding a New Chain
  8. Testing
  9. 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

  1. Merchant calls POST /v1/payment_intents with API key.
  2. API derives a unique payment address from the merchant's xpub (BIP44) and creates a PaymentIntent record.
  3. API returns checkout_url (e.g. https://checkout.rexpay.co/pay/<client_secret>).
  4. Customer opens checkout URL, sees QR code with the payment address and amount.
  5. Celery beat triggers watch_all_chains every 10 seconds.
  6. Watcher detects the inbound transaction and transitions the PaymentIntent to detected.
  7. Confirmation tracker updates confirmation count every 30 seconds. When the threshold is met, status becomes confirmed.
  8. Each status transition creates a PaymentEvent which 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: pendingsuccess or failed (after max attempts).

Signing format (matching Stripe's approach):

X-Webhook-Signature: t=<unix_timestamp>,v1=<hmac_sha256_hex>

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:

  • xpub mode: A new address is derived for each PaymentIntent by atomically incrementing derivation_index_cursor in the database (SELECT FOR UPDATE). This is the recommended mode.
  • address mode: 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's listsinceblock RPC 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:

RequireApiKey       # Accepts both test and live keys
RequireLiveApiKey   # Accepts only live keys

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:

class Meta:
    db_table = 'payments_dogetransaction'

A module-level alias is exported for any import paths that still use the old name:

DogeTransaction = ChainTransaction

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_doge vs. 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:

app.conf.task_queues = (
    ...
    Queue('xyz', routing_key='xyz'),
)

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:

volumes:
  ...
  xyz_data:

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:

dogecoin:DAddress?amount=100.5
litecoin:LAddress?amount=0.01

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:

from utils.auth import is_test_mode

if is_test_mode(request):
    # Use testnet config

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

docker compose exec api python manage.py shell
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_salt properties, not amount_doge directly.
  • Use chain_info.symbol for display labels (e.g. "DOGE", "LTC", "USDT").
  • Use chain_info.chain_type to branch between UTXO and account-based logic.
  • Use get_chain_info(chain_code) from services.chain.registry rather 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:

# Correct
from services.chain.manager import get_multi_chain_manager
mcm = get_multi_chain_manager()
provider = mcm.get_provider('DOGE')

# Incorrect — bypasses health checking and fallback logic
from services.chain.dogecoin_core import DogecoinCoreProvider
provider = DogecoinCoreProvider()