Reference

Architecture

eIOU Docker Architecture

Technical architecture documentation for the eIOU Docker node implementation.

Table of Contents

  1. Overview
  2. System Architecture Diagram
  3. Core Components
  4. Service Layer
  5. Dependency Injection Patterns
  6. Circular Dependency Management
  7. Message Processing Pipeline
  8. Data Layer
  9. P2P Networking
  10. Transaction Lifecycle
  11. Startup Sequence
  12. Docker Topologies
  13. GUI Architecture
  14. CLI Architecture
  15. Payload Schemas
  16. Security Model
  17. Error Handling
  18. Related Documentation

Overview

eIOU Docker is a peer-to-peer (P2P) payment system implemented as self-contained Docker containers. Each node operates as an independent payment processing unit capable of sending, receiving, and routing transactions across a decentralized network.

What Is an eIOU Node?

An eIOU node is a complete, isolated payment system running in a Docker container. Each node contains:

  • Wallet: BIP39-based cryptographic wallet with secp256k1 keypairs
  • Database: MariaDB instance storing contacts, transactions, and routing data
  • Processors: Background daemons for transaction, P2P, and cleanup processing
  • APIs: REST API, CLI interface, and Web GUI for node interaction

Key Characteristics

Characteristic Description
Self-Contained Each node has its own database, wallet, and processing
Decentralized No central server; nodes communicate peer-to-peer
Privacy-First Supports Tor hidden services for anonymous communication
Fault-Tolerant Automatic recovery from crashes, transaction replay protection

Access Points

Nodes provide three interfaces for interaction:

+------------------+     +------------------+     +------------------+
|    REST API      |     |       CLI        |     |     Web GUI      |
|  (Port 8080)     |     |  (eiou command)  |     |  (Port 8080)     |
+------------------+     +------------------+     +------------------+
        |                        |                        |
        +------------------------+------------------------+
                                 |
                    +------------------------+
                    |    ServiceContainer    |
                    +------------------------+
  • REST API: HTTP/HTTPS endpoints for programmatic access (see /docs/reference/api-reference)
  • CLI: Command-line interface via eiou command (see /docs/reference/cli-reference)
  • Web GUI: Browser-based interface served on same port (see /docs/reference/gui-reference)

System Architecture Diagram

High-Level Architecture

                           EXTERNAL CLIENTS
                                  |
                    +-------------+-------------+
                    |             |             |
               REST API         CLI          Web GUI
                    |             |             |
                    +-------------+-------------+
                                  |
                    +-------------v-------------+
                    |          nginx            |
                    |   (SSL/HTTPS + PHP-FPM)   |
                    +-------------+-------------+
                                  |
                    +-------------v-------------+
                    |       Application         |
                    |       (Singleton)         |
                    +-------------+-------------+
                                  |
          +-----------------------+------------------------+
          |                       |                        |
+---------v---------+   +---------v---------+   +----------v----------+
|   UserContext     |   | ServiceContainer  |   |   UtilityContainer  |
|  (Wallet Config)  |   |   (DI Container)  |   |  (Helper Services)  |
+-------------------+   +---------+---------+   +---------------------+
                                  |
        +-------------------------+-------------------------+
        |           |             |            |            |
+-------v---+ +-----v-----+ +-----v-----+ +----v----+ +-----v------+
|Transaction| |    P2p    | |  Contact  | |  Sync   | |  Message   |
|  Service  | |  Service  | |  Service  | | Service | |  Delivery  |
+-----------+ +-----------+ +-----------+ +---------+ +------------+
        |           |             |            |            |
        +-------------------------+-------------------------+
                                  |
                    +-------------v-------------+
                    |       Repositories        |
                    |   (Data Access Layer)     |
                    +-------------+-------------+
                                  |
                    +-------------v-------------+
                    |        MariaDB            |
                    |      (Persistent)         |
                    +---------------------------+

Background Processors

                    +---------------------------+
                    |       startup.sh          |
                    |    (Container Entry)      |
                    +-------------+-------------+
                                  |
          +-----------------------+------------------------+
          |             |                |                 |
+---------v----+ +------v-------+ +------v-------+ +-------v--------+
| Transaction  | |     P2P      | |   Cleanup    | | ContactStatus  |
|  Processor   | |  Processor   | |  Processor   | |   Processor    |
| (100ms-5s)   | | (100ms-5s)   | | (1s-30s)     | |  (5 min cycle) |
+--------------+ +--------------+ +--------------+ +----------------+
                                  |
                    +-------------v-------------+
                    |        Watchdog           |
                    |  (Process Monitor 30s)    |
                    +---------------------------+

Core Components

Application Singleton

The Application class (/src/core/Application.php) is the central entry point that manages global state and coordinates component initialization.

// Access the Application singleton
$app = Application::getInstance();

// Access services
$transactionService = $app->services->getTransactionService();

Responsibilities:

Responsibility Description
Database Setup Creates database and runs migrations on first startup
PDO Connection Maintains singleton PDO connection for all components
User Loading Loads UserContext from configuration files
Service Wiring Initializes ServiceContainer and wires circular dependencies
Recovery Runs transaction recovery for CLI/daemon processes

Key Properties:

class Application {
    protected $currentUser;      // UserContext instance
    protected $currentDatabase;  // DbContext instance
    protected $pdo;              // PDO connection
    public $services;            // ServiceContainer instance
    public $utilityServices;     // UtilityServiceContainer instance
    public array $processors;    // Cached processor instances
    public array $utils;         // Cached utility instances
}

UserContext

The UserContext class (/src/core/UserContext.php) provides access to wallet configuration and user-specific settings.

Configuration Files:

File Purpose
/etc/eiou/config/defaultconfig.json System defaults (fee limits, P2P settings)
/etc/eiou/config/userconfig.json User-specific settings (keys, addresses)

Key Methods:

$user = UserContext::getInstance();
$user->getPublicKey();      // Get user's public key
$user->getPublicKeyHash();  // Get public key hash for identification
$user->get('hostname');     // Get any configuration value
$user->getAuthCode();       // Get authentication code

Constants

The Constants class (/src/core/Constants.php) centralizes application-wide configuration values, replacing magic numbers throughout the codebase.

Key Configuration Categories:

Category Example Constants
Polling Intervals TRANSACTION_MIN_INTERVAL_MS, P2P_MAX_INTERVAL_MS
Transaction Limits TRANSACTION_MAX_AMOUNT, TRANSACTION_MINIMUM_FEE
P2P Network P2P_DEFAULT_MAX_REQUEST_LEVEL, P2P_DEFAULT_EXPIRATION_SECONDS
Contact Settings CONTACT_DEFAULT_CREDIT_LIMIT, CONTACT_STATUS_ENABLED
Security HASH_ALGORITHM, RECOVERY_MAX_RETRY_COUNT

Wallet

The Wallet class (/src/core/Wallet.php) handles BIP39 seed phrase generation and cryptographic key derivation.

Capabilities:

  • Generate new 24-word BIP39 mnemonic seed phrases
  • Restore wallets from existing seed phrases (CLI or file-based)
  • Derive deterministic secp256k1 keypairs from seeds
  • Generate Tor hidden service addresses
  • Create authentication codes for secure access

Security Notes:

  • Private keys and mnemonics are encrypted before storage
  • File permissions are set to 0600 (owner read/write only)
  • File-based restore prevents exposure in process listings

Service Layer

ServiceContainer Overview

The ServiceContainer class (/src/services/ServiceContainer.php) implements the dependency injection pattern, providing centralized management of service instances. It implements PSR-11 ContainerInterface and integrates with PHP-DI for autowiring.

Key Features:

Feature Description
PSR-11 Compliant Implements ContainerInterface with get() and has() methods
PHP-DI Integration Uses PHP-DI container for autowiring and interface bindings
Singleton Pattern Single instance manages all services
Lazy Loading Services instantiated on first access
Circular Dependency Handling Setter injection via wireAllServices()
Testability Services can be mocked via registerService()

PHP-DI Configuration:

The container configuration is defined in /src/config/container.php:

// Interface to implementation bindings
ContactServiceInterface::class => get(ContactService::class),
TransactionServiceInterface::class => get(TransactionService::class),

// Autowired repositories (receive PDO automatically)
AddressRepository::class => autowire(),
ContactRepository::class => autowire(),

Initialization Flow:

Application::getInstance()
    -> loadServiceContainer()
        -> ServiceContainer::getInstance()
            -> buildPhpDiContainer() (lazy, on first DI access)
    -> services->wireAllServices()
        -> Initialize core services
        -> wireCircularDependencies()

Accessing Services via PSR-11:

// Traditional getter method (still supported)
$contactService = $container->getContactService();

// PSR-11 interface method
$contactService = $container->get(ContactServiceInterface::class);

// Check if service exists
if ($container->has(ContactServiceInterface::class)) {
    // ...
}

Service Catalog

Service Purpose Key Dependencies
TransactionService Facade for transaction operations, delegates to specialized services BalanceService, ChainVerificationService, TransactionValidationService, TransactionProcessingService, SendOperationService, P2pService, ContactService, SyncTriggerProxy
TransactionRefundService Shared core for returning a received transaction to its original sender, in full or in parts. Caller passes a txid and an optional amount; the recipient (original sender) and currency are derived from the stored record, and the amount defaults to the un-refunded remainder and is capped so the total returned never exceeds the amount received (received-only; an in-flight refund counts toward the cap, so the limit holds without a race). The set of refund rows is the authoritative record of what has been returned, replacing any single “refunded” flag that could drift. One implementation behind every refund surface: GUI (TransactionController action), CLI (eiou refund <txid> [amount]), REST (POST /api/v1/wallet/refund), and the plugin gateway (WalletRefundService, permission wallet_refund_return_to_sender, which takes the same optional partial amount and forwards it to the core). Each refund is an ordinary outgoing transaction whose signed description carries a marker to the original; the local-only refund_of_txid link is recognised from that marker on insert (so it self-heals on resync) and is never transmitted. TransactionService (sendEiou), TransactionRepository
BalanceService Balance calculations and currency conversions BalanceRepo, TransactionContactRepo, AddressRepo, CurrencyUtility
ChainVerificationService Transaction chain integrity verification TransactionChainRepo, SyncTriggerProxy
TransactionValidationService Transaction validation with proactive sync TransactionRepo, ContactRepo, ValidationUtility, SyncTriggerProxy, TransactionService
TransactionProcessingService Transaction processing with atomic claiming; updates P2P sender address on relay when actual transaction sender differs from stored sender TransactionRepo, TransactionRecoveryRepo, TransactionChainRepo, P2pRepo, BalanceRepo, SyncTriggerProxy, P2pService, HeldTransactionService
SendOperationService Send orchestration with distributed locking TransactionRepo, AddressRepo, P2pRepo, TransportUtility, LockingService, ContactService, P2pService, SyncTriggerProxy, TransactionService, TransactionChainRepo, ChainDropService
P2pService Peer-to-peer message routing; mega-batch broadcast via sendMultiBatch() with coalesce delay, handles fast/best-fee mode (fast forced for Tor), tracks multi-path senders, currency-filtered contact selection, creates capacity reservations on relay, broadcasts full cancel downstream to all contacts via broadcastFullCancelForHash() ContactRepo, P2pRepo, P2pSenderRepo, ContactCurrencyRepo, CapacityReservationRepo, TransportUtility, MessageDeliveryService
Rp2pService Return P2P (response) message handling; candidate storage and best-fee selection with fallback iteration, rejection counting in fast mode, per-currency fee lookup, triggers route cancellation for unselected candidates ContactRepo, Rp2pRepo, Rp2pCandidateRepo, P2pRepo, ContactCurrencyRepo, SendOperationService (via P2pTransactionSenderInterface), RouteCancellationService
RouteCancellationService Actively cancels unselected P2P routes after best-fee selection; releases capacity reservations, sends route_cancel messages; handles incoming cancellations with two modes: partial (acknowledge only, multi-route safe) and full cancel (cancel P2P, release reservation, propagate downstream); randomized hop budget via geometric distribution (integrated into P2pService originator hop calculation via static computeHopBudget()); controllable via EIOU_HOP_BUDGET_RANDOMIZED env var P2pService (via P2pServiceInterface), CapacityReservationRepo, RouteCancellationRepo, P2pRepo
ContactService Contact management facade ContactRepo, AddressRepo, TransactionContactRepo, SyncTriggerProxy, MessageDeliveryService
ContactManagementService Contact CRUD and blocking ContactRepo, ContactSyncService
ContactSyncService Contact-level sync operations ContactRepo, SyncTriggerProxy, MessageDeliveryService
ContactStatusService Contact ping/status checking; auto-creates pending contacts for unknown pings (wallet restore scenario) ContactRepo, TransactionRepo, SyncTriggerProxy, TransactionChainRepo, RateLimiterService, ChainDropService
SyncService Transaction chain synchronization ContactRepo, AddressRepo, P2pRepo, Rp2pRepo, TransactionRepo, TransactionChainRepo, TransactionContactRepo, BalanceRepo, UtilityContainer, HeldTransactionService, BackupService
ChainDropService Tx drop agreement protocol with auto-accept balance guard ChainDropProposalRepo, TransactionChainRepo, TransactionRepo, ContactRepo, UtilityContainer, BackupService, SyncTriggerProxy, BalanceRepo
ChainOperationsService Centralized chain verification/repair SyncService
MessageDeliveryService Reliable delivery with retry/DLQ MessageDeliveryRepo, DeadLetterQueueRepo, TransportUtility
HeldTransactionService Pending transaction queue for sync HeldTransactionRepo, TransactionRepo, TransactionChainRepo (uses EventDispatcher for sync notifications)
CleanupService Expired message/proposal cleanup; releases expired capacity reservations, prunes old cancellation records P2pRepo, Rp2pRepo, TransactionRepo, ChainDropService, CapacityReservationRepo, RouteCancellationRepo
BackupService Encrypted backup and restore TransactionRepo
WalletService Wallet information access UserContext
PaymentRequestService Payment request lifecycle — create, approve (triggers sendEiou), decline, cancel, handle incoming request/response messages PaymentRequestRepo, TransactionService, MessageDeliveryService, ContactRepo, TransportUtility
PaymentRequestArchivalService Nightly batch move of resolved payment requests from payment_requests → payment_requests_archive once responded_at is older than paymentRequestsArchiveRetentionDays. Invoked by payment-request-archive-cron.php at 01:00 UTC. Supports --dry-run (count only). After any successful move (moved > 0) calls BackupService::createArchiveBackup() + cleanupOldBackups() so the cold backup is refreshed; backup failures are logged but never fail archival PaymentRequestArchiveRepo, UserContext, BackupService (optional)
MessageService Incoming message routing ContactRepo, BalanceRepo, P2pRepo, TransactionRepo, TransactionContactRepo, SyncTriggerProxy, ChainDropService, PaymentRequestService
ApiAuthService API authentication (HMAC-SHA256) ApiKeyRepo
ApiKeyService API key management ApiKeyRepo
TransactionRecoveryService Stuck transaction recovery TransactionRecoveryRepo
RateLimiterService Request rate limiting RateLimiterRepo
DatabaseLockingService Distributed locking via MariaDB GET_LOCK() / RELEASE_LOCK() PDO
CliService CLI output formatting ContactRepo, BalanceRepo, TransactionRepo + setter: ContactCreditRepo, P2pRepo
DebugService Debug logging and diagnostics DebugRepo
DebugReportService Generates debug reports (limited/full) with system info, logs, and diagnostics DebugService, Logger
AnalyticsService Opt-in anonymous usage statistics — collects aggregate metrics (transaction counts, volume, contact count, active days) and sends a single daily heartbeat to analytics.eiou.org (cron at 03:00 UTC, not real-time). Anonymous ID is HMAC-SHA256 hash. Routed through the local Tor SOCKS5 proxy so the operator’s egress IP is never visible to analytics.eiou.org. Disabled by default UserContext, TransactionRepo, ContactRepo
UpdateCheckService Checks Docker Hub (with GitHub Releases fallback) for newer image versions. Cached for 24 hours. Respects updateCheckEnabled setting. All outbound requests are routed through the local Tor SOCKS5 proxy so Docker Hub and GitHub never log the operator’s egress IP. If Tor is down, the check fails silently and retries on the next cron tick UserContext
CliDlqService CLI dead letter queue operations (list, retry, abandon) DeadLetterQueueRepo, TransportUtility
CliHelpService CLI help/documentation generation None
CliP2pApprovalService CLI P2P transaction approval workflow P2pRepo, Rp2pCandidateRepo
CliSettingsService CLI settings read/write UserContext

Utility Services

The UtilityServiceContainer provides helper services for common operations:

Utility Purpose
TimeUtilityService Timestamp formatting, timezone handling
CurrencyUtilityService Amount conversion, formatting
ValidationUtilityService Input validation, sanitization
GeneralUtilityService Miscellaneous helpers shared across services (ServiceContainer + UserContext access)
TransportUtilityService HTTP/HTTPS/Tor message transport; parallel batch sends via curl_multi with per-protocol concurrency limits

Circular Dependency Resolution

Some services have circular dependencies (e.g., TransactionService needs SyncService and vice versa). These are resolved via setter injection:

// In ServiceContainer::wireCircularDependencies()

// Core sync-related dependencies (via SyncTriggerInterface proxy for loose coupling)
$this->services['TransactionService']->setSyncTrigger($this->getSyncServiceProxy());
$this->services['SyncService']->setHeldTransactionService($this->services['HeldTransactionService']);

// Contact services
$this->services['ContactManagementService']->setContactSyncService($this->services['ContactSyncService']);
$this->services['ContactSyncService']->setSyncTrigger($this->getSyncServiceProxy());
$this->services['ContactSyncService']->setMessageDeliveryService($this->services['MessageDeliveryService']);
$this->services['ContactService']->setSyncTrigger($this->getSyncServiceProxy());
$this->services['ContactStatusService']->setSyncTrigger($this->getSyncServiceProxy());
$this->services['ContactStatusService']->setTransactionChainRepository($this->getTransactionChainRepository());
$this->services['ContactStatusService']->setRateLimiterService($this->services['RateLimiterService']);

// MessageService handles sync requests and tx drop messages
$this->services['MessageService']->setSyncTrigger($this->getSyncServiceProxy());
$this->services['MessageService']->setChainDropService($this->services['ChainDropService']);

// RP2P uses P2pTransactionSenderInterface to break circular dependency
$this->services['Rp2pService']->setP2pTransactionSender($this->services['SendOperationService']);

// TransactionService facade receives P2p, Contact, and 5 specialized services
$this->services['TransactionService']->setP2pService($this->services['P2pService']);
$this->services['TransactionService']->setContactService($this->services['ContactService']);
$this->services['TransactionService']->setBalanceService($this->services['BalanceService']);
$this->services['TransactionService']->setChainVerificationService($this->services['ChainVerificationService']);
$this->services['TransactionService']->setTransactionValidationService($this->services['TransactionValidationService']);
$this->services['TransactionService']->setTransactionProcessingService($this->services['TransactionProcessingService']);
$this->services['TransactionService']->setSendOperationService($this->services['SendOperationService']);

// Specialized services use SyncTriggerInterface proxy
$this->services['ChainVerificationService']->setSyncTrigger($this->getSyncServiceProxy());
$this->services['TransactionValidationService']->setSyncTrigger($this->getSyncServiceProxy());
$this->services['TransactionValidationService']->setTransactionService($this->services['TransactionService']);
$this->services['TransactionProcessingService']->setSyncTrigger($this->getSyncServiceProxy());
$this->services['TransactionProcessingService']->setP2pService($this->services['P2pService']);
$this->services['TransactionProcessingService']->setHeldTransactionService($this->services['HeldTransactionService']);
$this->services['SendOperationService']->setContactService($this->services['ContactService']);
$this->services['SendOperationService']->setP2pService($this->services['P2pService']);
$this->services['SendOperationService']->setSyncTrigger($this->getSyncServiceProxy());
$this->services['SendOperationService']->setTransactionService($this->services['TransactionService']);
$this->services['SendOperationService']->setTransactionChainRepository($this->getTransactionChainRepository());
$this->services['SendOperationService']->setChainDropService($this->services['ChainDropService']);

// Chain operations and backup recovery
$this->services['ChainOperationsService']->setSyncService($this->services['SyncService']);
$this->services['SyncService']->setBackupService($this->getBackupService());
$this->services['ChainDropService']->setBackupService($this->getBackupService());
$this->services['CleanupService']->setChainDropService($this->services['ChainDropService']);

// CliService - repositories for info command display (fee earnings, available credit)
$this->services['CliService']->setContactCreditRepository($this->getContactCreditRepository());
$this->services['CliService']->setP2pRepository($this->getP2pRepository());

Dependency Injection Patterns

The codebase uses several patterns to manage service dependencies while avoiding tight coupling and circular dependencies.

Interface Segregation

Services depend on focused interfaces rather than concrete implementations. This reduces coupling and makes circular dependencies easier to break.

Key Interfaces:

Interface Purpose Implementing Service
SyncTriggerInterface Minimal sync operations for chain repair SyncService, SyncServiceProxy
P2pTransactionSenderInterface P2P transaction sending P2pService
ChainOperationsInterface Chain verification and repair ChainOperationsService
LockingServiceInterface Distributed locking DatabaseLockingService
EventDispatcherInterface Event-driven communication EventDispatcher
RouteCancellationServiceInterface Route cancellation and hop budget RouteCancellationService

Example - SyncTriggerInterface:

// SyncTriggerInterface defines only the methods other services need
interface SyncTriggerInterface
{
    public function syncTransactionChain(string $contactAddress, string $contactPublicKey, ?string $expectedTxid = null): array;
    public function syncContactBalance(string $contactPubkey): array;
    public function syncSingleContact($contactAddress, $echo = 'SILENT'): bool;
    public function syncReaddedContact(string $contactAddress, string $contactPublicKey): array;
}

// Services depend on the interface, not the concrete SyncService
class HeldTransactionService
{
    private ?SyncTriggerInterface $syncService = null;

    public function setSyncService(SyncTriggerInterface $syncService): void {
        $this->syncService = $syncService;
    }
}

Event-Driven Communication

The EventDispatcher enables loose coupling by allowing services to communicate via events instead of direct dependencies.

SyncEvents Constants:

Event When Dispatched
SYNC_COMPLETED After successful sync operation
SYNC_FAILED When sync operation fails
CHAIN_GAP_DETECTED When missing transactions detected
BALANCE_SYNCED After contact balance sync
CONTACT_SYNCED After contact sync completes
CHAIN_CONFLICT_RESOLVED When chain conflict is resolved

ChainDropEvents Constants:

Event When Dispatched
CHAIN_DROP_PROPOSED When a tx drop is proposed to a contact
CHAIN_DROP_ACCEPTED When a tx drop proposal is accepted
CHAIN_DROP_REJECTED When a tx drop proposal is rejected
CHAIN_DROP_EXECUTED When a tx drop has been fully executed locally
TRANSACTION_RECOVERED_FROM_BACKUP When a missing transaction is recovered from a database backup instead of requiring a tx drop

DeliveryEvents Constants:

Event When Dispatched
RETRY_DELIVERY_COMPLETED When a retried message (from DLQ) is successfully re-delivered to the recipient

Usage Example:

// Subscribe to events (typically in service constructor or bootstrap)
EventDispatcher::getInstance()->subscribe(SyncEvents::SYNC_COMPLETED, function($data) {
    $contactPubkey = $data['contact_pubkey'];
    $syncedCount = $data['synced_count'];
    // React to sync completion...
});

// Dispatch events (in the service performing the action)
EventDispatcher::getInstance()->dispatch(SyncEvents::SYNC_COMPLETED, [
    'contact_pubkey' => $pubkey,
    'synced_count' => 5,
    'success' => true
]);

Lazy Proxy Pattern

SyncServiceProxy delays service resolution until first use, breaking circular dependencies at construction time.

// SyncServiceProxy delays resolution until a method is called
class SyncServiceProxy implements SyncTriggerInterface
{
    private ServiceContainer $container;
    private ?SyncService $instance = null;

    public function __construct(ServiceContainer $container) {
        $this->container = $container;
    }

    private function getService(): SyncService {
        if ($this->instance === null) {
            $this->instance = $this->container->getSyncService();
        }
        return $this->instance;
    }

    public function syncTransactionChain(...): array {
        return $this->getService()->syncTransactionChain(...);
    }
}

When to Use Proxies:

  • Service A depends on Service B at runtime but not at construction
  • Breaking a circular dependency where setter injection is not suitable
  • Deferring expensive service initialization

Constructor vs Setter Injection Guidelines

Use Case Pattern Example
Required dependencies Constructor injection Repositories, utilities, PDO
Optional dependencies Setter injection with null default Debug services
Circular dependencies Setter injection SyncService <-> HeldTransactionService
Late-bound dependencies Lazy proxy SyncServiceProxy
Deferred repo wiring Setter injection with null guard CliService (ContactCreditRepo, P2pRepo)

Constructor Injection (Required - No Fallbacks):

Dependencies must be explicitly provided via constructor injection. There are no automatic fallbacks to ServiceContainer - if a dependency is not provided, the code will fail fast with a clear error.

class SettingsController
{
    private Session $session;
    private ?PDO $pdo;

    // PDO must be injected - no ServiceContainer fallback
    public function __construct(Session $session, ?PDO $pdo = null)
    {
        $this->session = $session;
        $this->pdo = $pdo;
    }

    private function getPdoConnection(): ?PDO
    {
        return $this->pdo;  // Returns injected value only
    }
}

Service Layer Constructor Injection:

class BalanceService
{
    public function __construct(
        BalanceRepository $balanceRepository,          // Required
        TransactionContactRepository $transactionContactRepository,
        AddressRepository $addressRepository,
        CurrencyUtilityService $currencyUtility
    ) {
        // All dependencies available immediately
    }
}

Setter Injection (For Circular Dependencies):

class TransactionService
{
    private ?SyncTriggerInterface $syncTrigger = null;

    public function setSyncTrigger(SyncTriggerInterface $syncTrigger): void {
        $this->syncTrigger = $syncTrigger;
    }

    private function getSyncTrigger(): SyncTriggerInterface {
        if ($this->syncTrigger === null) {
            throw new RuntimeException(
                'SyncTrigger not injected. Call setSyncTrigger() or ensure ' .
                'ServiceContainer::wireCircularDependencies() is called.'
            );
        }
        return $this->syncTrigger;
    }
}

Dependency Graph

                         +------------------+
                         | ServiceContainer |
                         +--------+---------+
                                  |
     +-------------+--------------+--------------+------------+
     |             |              |              |            |
+----v----+  +-----v------+ +----v-----+  +------v----+ +-----v----+
|  Sync   |  |Transaction |  | Contact |  |  Message  | | Cleanup  |
| Service |  |  Service   |  | Service |  |  Service  | | Service  |
+----+----+  +-----+------+ +----+-----+  +-----+-----+ +-----+----+
     |             |             |              |             |
     |    +--------+--------+    |              |             |
     |    |        |        |    |              |             |
+----v--+ v   +----v---+ +--v----v-+   +--------v----+ +------v----+
| Held  | |   |  Send  | |  Chain  |   |  ChainDrop  | | Backup    |
|  Tx   | |   |  Op    | |  Verif  |   |  Service    | | Service   |
|Service| |   |Service | | Service |   +-------------+ +-----------+
+-------+ |   +----+---+ +--------+          ^               ^
          |        |                         |               |
     +----v----+   +--- ChainDropService     +--- Setter     |
     | Balance |   +--- SyncTriggerProxy     +--- Setter ----+
     | Service |
     +---------+

Legend:
  -----> Constructor injection
  ··· > Setter injection via SyncTriggerInterface proxy (loose coupling)

Circular Dependency Management

Why Setter Injection Exists

Some services have dependencies that require setter injection due to initialization order constraints. Most circular dependencies have been eliminated:

Dependency Pattern Notes
SyncService -> HeldTransactionService Setter injection Sync notifies held transaction service
CliService -> ContactCreditRepo, P2pRepo Setter injection Info command displays fee earnings and available credit

How wireCircularDependencies() Works

ServiceContainer::wireCircularDependencies() is called after all services are constructed to wire up setter-injected dependencies:

public function wireCircularDependencies(): void {
    // Core sync-related dependencies (SyncTriggerInterface via proxy)
    $this->services['TransactionService']->setSyncTrigger($this->getSyncServiceProxy());
    $this->services['SyncService']->setHeldTransactionService($this->services['HeldTransactionService']);
    // Note: HeldTransactionService uses EventDispatcher for sync notifications (no setter injection)

    // Contact services
    $this->services['ContactManagementService']->setContactSyncService($this->services['ContactSyncService']);
    $this->services['ContactSyncService']->setSyncTrigger($this->getSyncServiceProxy());
    $this->services['ContactSyncService']->setMessageDeliveryService($this->services['MessageDeliveryService']);
    $this->services['ContactService']->setSyncTrigger($this->getSyncServiceProxy());
    $this->services['ContactStatusService']->setSyncTrigger($this->getSyncServiceProxy());
    $this->services['ContactStatusService']->setChainDropService($this->services['ChainDropService']);

    // Message service handles sync and tx drop routing
    $this->services['MessageService']->setSyncTrigger($this->getSyncServiceProxy());
    $this->services['MessageService']->setChainDropService($this->services['ChainDropService']);

    // RP2P uses P2pTransactionSenderInterface (breaks circular dependency)
    $this->services['Rp2pService']->setP2pTransactionSender($this->services['SendOperationService']);

    // Specialized services use SyncTriggerInterface via proxy
    $this->services['ChainVerificationService']->setSyncTrigger($this->getSyncServiceProxy());
    $this->services['TransactionProcessingService']->setSyncTrigger($this->getSyncServiceProxy());
    $this->services['SendOperationService']->setContactService($this->services['ContactService']);
    $this->services['SendOperationService']->setChainDropService($this->services['ChainDropService']);

    // Chain operations and backup recovery
    $this->services['ChainOperationsService']->setSyncService($this->services['SyncService']);
    $this->services['SyncService']->setBackupService($this->getBackupService());
    $this->services['ChainDropService']->setBackupService($this->getBackupService());
    $this->services['ChainDropService']->setSyncTrigger($this->getSyncServiceProxy());
    $this->services['ChainDropService']->setBalanceRepository($this->getBalanceRepository());
    $this->services['CleanupService']->setChainDropService($this->services['ChainDropService']);

    // CliService - repositories for info command display (fee earnings, available credit)
    $this->services['CliService']->setContactCreditRepository($this->getContactCreditRepository());
    $this->services['CliService']->setP2pRepository($this->getP2pRepository());

    // Route cancellation system
    $this->services['RouteCancellationService']->setCapacityReservationRepository(...);
    $this->services['RouteCancellationService']->setRouteCancellationRepository(...);
    $this->services['RouteCancellationService']->setP2pRepository(...);
    $this->services['RouteCancellationService']->setP2pService($this->services['P2pService']);
    $this->services['Rp2pService']->setRouteCancellationService($this->services['RouteCancellationService']);
    $this->services['P2pService']->setCapacityReservationRepository(...);
    $this->services['CleanupService']->setCapacityReservationRepository(...);
    $this->services['CleanupService']->setRouteCancellationRepository(...);
}

Initialization Order:

1. Application::getInstance()
2. -> loadServiceContainer()
3. -> ServiceContainer::getInstance()
4. -> wireAllServices()
       -> Initialize all services (constructor injection)
       -> wireCircularDependencies() (setter injection)

Important: Every service that receives setter injection in wireCircularDependencies() must be initialized in wireAllServices() first. The wiring uses isset() guards, so if a service hasn’t been created yet, the setter injection is silently skipped and the service will have null dependencies at runtime.

Future Roadmap for Eliminating Cycles

The codebase has significantly reduced circular dependencies:

Strategy Status Services/Details
Lazy proxy pattern ✅ Available SyncServiceProxy for optional use

All SyncService Dependencies Now Use SyncTriggerInterface:

Only Remaining Direct SyncService Usage:

  • SyncService -> HeldTransactionService (one-way setter, not circular)
  • ChainOperationsService -> SyncService (for chain repair coordination)

CI Script for Cycle Detection

The circularDependencyCheck.sh script detects circular dependencies via static analysis:

# Run from eiou-docker root
cd tests/testfiles
./circularDependencyCheck.sh           # Normal output
./circularDependencyCheck.sh --verbose # Detailed dependency graph

How It Works:

  1. Parses all PHP service files in /files/src/services/
  2. Extracts constructor dependencies (type-hinted parameters)
  3. Extracts setter injection dependencies (set*Service methods)
  4. Builds a dependency graph
  5. Uses DFS to detect cycles
  6. Reports found cycles with dependency chains

Exit Codes:

Code Meaning
0 No circular dependencies found
1 Circular dependencies detected

Sample Output:

Circular Dependency Check
=========================

Analyzing files in /files/src/services...
Found 25 service files

Detecting cycles...

No circular dependencies detected in core services.

All major cycles have been eliminated using:
  - SyncTriggerInterface + SyncServiceProxy (ContactService, MessageService, TransactionService)
  - P2pTransactionSenderInterface (Rp2pService)
  - EventDispatcher + SyncEvents (HeldTransactionService)

Message Processing Pipeline

Processor Architecture

Four background processors handle asynchronous message processing. Each extends AbstractMessageProcessor which provides:

  • Adaptive Polling: Adjusts polling interval based on workload
  • Signal Handling: Graceful shutdown on SIGTERM, reload on SIGHUP
  • Lockfile Management: Ensures single instance per processor type
  • Statistics Logging: Periodic throughput reporting
                    +---------------------------+
                    | AbstractMessageProcessor  |
                    +---------------------------+
                    | - poller: AdaptivePoller  |
                    | - shouldStop: bool        |
                    | - lockfile: string        |
                    +---------------------------+
                    | + run()                   |
                    | + handleShutdownSignal()  |
                    | # processMessages()       |  <- Abstract
                    | # getProcessorName()      |  <- Abstract
                    +---------------------------+
                              ^
          +-------------------+-------------------+
          |         |                   |         |
+---------+-+ +-----+-------+ +---------+-+ +-----+-------+
|Transaction| |     P2P     | |  Cleanup  | |ContactStatus|
| Processor | |  Processor  | | Processor | |  Processor  |
+-----------+ +-------------+ +-----------+ +-------------+

TransactionMessageProcessor

Processes pending outbound transactions with fast polling for time-critical operations.

Setting Value
Min Interval 100ms
Max Interval 5000ms (5s)
Idle Interval 2000ms (2s)
Log Interval 60 seconds
Lockfile /tmp/transactionmessages_lock.pid

Processing Loop:

protected function processMessages(): int {
    return $this->transactionService->processPendingTransactions();
}

Fault isolation and poison-message quarantine. A peer message that throws while being processed no longer propagates out of the loop and kills the process (which the watchdog would respawn onto the same message, crash-looping). The loop isolates a failed cycle, each pending message is processed independently so one bad message does not abort the batch, and a message that fails QUARANTINE_THRESHOLD times in a row is moved out of the pending set. Quarantine sets needs_manual_review = 1 rather than a bare failed status: the trigger is “this txid threw repeatedly”, not “this message is provably poison”, so a systemic per-message fault (an unwired dependency, a partial outage) that trips the threshold on otherwise-legitimate rows leaves them recoverable and visible to the operator instead of silently dropped.

P2pMessageProcessor

The P2P processor uses a coordinator+worker architecture for parallel P2P broadcast. The coordinator (P2pMessageProcessor) polls for queued P2P messages and spawns independent worker processes (P2pWorker.php) for each one via proc_open.

Setting Value
Min Interval 100ms
Max Interval 5000ms (5s)
Idle Interval 2000ms (2s)
Log Interval 60 seconds
Lockfile /tmp/p2pmessages_lock.pid

Coordinator+Worker Model:

P2pMessageProcessor (Coordinator)
    |
    +-- Poll for queued P2P messages
    +-- For each queued P2P:
    |     +-- Check per-transport worker limit (HTTP: 50, Tor: 5)
    |     +-- Spawn P2pWorker.php via proc_open
    |     +-- Track worker by transport type independently
    |
    +-- Reap finished workers, log results
    +-- Recover stuck 'sending' P2Ps with dead worker PIDs (every 60s)

Worker Lifecycle:

Each P2pWorker.php process handles one P2P message end-to-end:

  1. Claim: Atomically transitions P2P from queued → sending via P2pRepository::claimQueuedP2p(), recording sending_started_at and sending_worker_pid for crash recovery
  2. Broadcast: Calls P2pService::processSingleP2p() which broadcasts to all accepted contacts via its own curl_multi session
  3. Complete: Transitions P2P from sending → sent

Worker Pool Configuration:

Setting Value Notes
P2P_MAX_WORKERS (HTTP) 50 Per-transport concurrent workers
P2P_MAX_WORKERS (HTTPS) 50 Per-transport concurrent workers
P2P_MAX_WORKERS (Tor) 5 Lower limit to prevent SOCKS5 circuit overload
P2P_SENDING_TIMEOUT_SECONDS 300 Crash recovery threshold for stuck workers
Override EIOU_P2P_MAX_WORKERS env var Per-deployment tuning

Crash Recovery:

The coordinator runs a recovery sweep every 60 seconds, finding P2P messages stuck in sending status beyond P2P_SENDING_TIMEOUT_SECONDS. If the worker PID is no longer alive, P2pRepository::recoverStuckP2p() resets the P2P to queued for re-processing.

P2P Status Flow:

queued → sending → sent → found → completed
                      ↘ expired / cancelled

Mega-Batch Processing (within each worker):

Each worker’s processSingleP2p() uses a 3-phase mega-batch approach to broadcast to all contacts in a single curl_multi call:

  1. Phase 1 — Collect: Builds per-contact payloads and accumulates them into a flat $megaBatchSends array. A coalesce delay (P2P_QUEUE_COALESCE_MS, 2000ms) groups concurrent P2Ps arriving within a short window.
  2. Phase 2 — Fire: Calls TransportUtilityService::sendMultiBatch($megaBatchSends) which executes all sends in parallel via curl_multi with a sliding-window concurrency limit (see Transport Concurrency Control).
  3. Phase 3 — Map Results: Maps each curl_multi result back to its originating P2P by key, processes responses, and updates P2P status accordingly.

CleanupMessageProcessor

Removes expired P2P and transaction messages with slower polling (less time-critical).

Setting Value
Min Interval 1000ms (1s)
Max Interval 30000ms (30s)
Idle Interval 10000ms (10s)
Log Interval 300 seconds (5 min)
Lockfile /tmp/cleanupmessages_lock.pid

Processing Loop:

protected function processMessages(): int {
    return $this->cleanupService->processCleanupMessages();
}

ContactStatusProcessor

Periodically pings accepted contacts to check online status and validate transaction chains. Operates in 5-minute cycles.

Setting Value
Cycle Interval 300000ms (5 min)
Max Interval 1800000ms (30 min)
Log Interval 60 seconds
Lockfile /tmp/contact_status.pid

Features:

  • Pings one contact per iteration to spread load
  • Updates contact online status (online/partial/offline/unknown)
  • Validates per-currency transaction chain integrity (prevTxidsByCurrency maps)
  • Triggers sync if any currency’s chain heads don’t match
  • Auto-proposes tx drop if sync detects mutual gaps (both sides missing the same transaction(s))
  • Auto-creates pending contact records for unknown incoming pings (wallet restore scenario)
  • Respects EIOU_CONTACT_STATUS_ENABLED environment variable

Privacy: Tor-only by design (no transport fallback):

Pings deliberately use the contact’s Tor address with no fallback to HTTPS or HTTP, even when the user’s general-purpose torFailureTransportFallback setting is enabled. This is intentional and asymmetric with TransportUtilityService::send(), which does fall back.

The address-priority chain in ContactStatusService::pingContact() is tor > https > http, and on Tor failure the method returns a structured tor_unavailable error rather than retrying over a clearer-text transport.

The reason is metadata privacy. Ping payloads are E2E encrypted via ECDH + AES-256-GCM (PayloadEncryption::encryptForRecipient() — see TransportUtilityService::signWithCapture(), where only type='create' is excluded), so an HTTPS observer can’t read the body. But pings are also small, frequent, and emit a recognizable timing/size pattern — exactly the kind of traffic that lets a network observer reconstruct the contact graph (who-pings-whom-and-how-often) from metadata alone:

Observer Sees over HTTPS Sees over Tor
Local ISP / coffee-shop net Source IP, destination SNI hostname, packet sizes, timing Encrypted Tor traffic to a guard relay; nothing about the actual peer
Peer’s ISP / hosting provider Your real IP, connection times Tor exit (or rendezvous, for .onion) — no link to you
State-level adversary correlating both ends The full contact graph with timing Distributed across three relays; no single observer holds both ends

Because content is already encrypted, switching from Tor to HTTPS for ping doesn’t restore content secrecy — it only weakens metadata privacy below what the user opted into when they enabled Tor. So pingContact() fails closed (returns tor_unavailable, suggests retry — see SECURITY.md Network Security for details), and the watchdog attempts a Tor restart within ~30 seconds via /tmp/tor-restart-requested.

TransportUtilityService::send() (regular transactions) keeps fallback because the value tradeoff is different: a delivered transaction with weaker metadata privacy is usually preferable to a failed payment, and operators can opt out via the setting. Pings are a status-only operation where reliability matters less than the privacy guarantee.

Wallet Restore Contact Re-establishment:

When a wallet is restored from a seed phrase (empty database, same keys/address) and a former contact pings the restored node, the following flow occurs:

  1. Signature verification — the incoming ping signature is verified against the sender’s public key included in the request itself (ValidationUtilityService::verifyRequestSignature). No contact database record is needed; this is a self-contained cryptographic check.
  2. Unknown sender detected — ContactStatusService::handlePingRequest() finds the sender pubkey is not in the contacts table and creates a pending contact record named RestoredContact<N> (sequentially numbered).
  3. Sync triggered — the node pulls the transaction chain from the remote node via SyncTriggerInterface::syncTransactionChain().
  4. Prior relationship verified — if the sync restores transactions between the two pubkeys, this proves a prior relationship existed.
  5. Accept or hold for review — controlled by autoAcceptRestoredContact setting (env: EIOU_AUTO_ACCEPT_RESTORED_CONTACT, default: true):
    • Enabled (default): the contact is auto-accepted with the node’s default fee (Constants::CONTACT_DEFAULT_FEE_PERCENT, 0% by default) and default credit limit (Constants::CONTACT_DEFAULT_CREDIT_LIMIT, 1000) for all restored currencies. Balances are recalculated from the synced transaction history.
    • Disabled: the contact stays pending for manual review. Balances are still synced so the user can see the transaction history when deciding whether to accept.

What is NOT restored automatically (even with auto-accept enabled):

  • Contact name — set to RestoredContact<N>, must be renamed manually
  • Credit limits per contact per currency — reset to the node’s default
  • Fee percentages per contact per currency — reset to the node’s default

The original negotiated terms (e.g., a 5000 credit limit with 0.5% fee) are lost because they were stored only in the local database. The user must manually reconfigure these after the contact is re-established.

Watchdog Monitoring

The watchdog runs every 30 seconds and monitors processor health and Tor hidden-service health:

+------------------+
|    Watchdog      |
| (30s interval)   |
+------------------+
        |
        +-- Shutdown flag exists?
        |       |-- Yes: Skip all checks (processors intentionally stopped)
        |       |-- Flag just cleared? Reset all restart counters
        |
        +-- Check P2P PID alive?
        |       |-- No: Restart (if < 10 restarts, > 60s cooldown)
        |
        +-- Check Transaction PID alive?
        |       |-- No: Restart (if < 10 restarts, > 60s cooldown)
        |
        +-- Check Cleanup PID alive?
        |       |-- No: Restart (if < 10 restarts, > 60s cooldown)
        |
        +-- Check ContactStatus PID alive? (if enabled)
        |       |-- No: Restart (if < 10 restarts, > 60s cooldown)
        |
        +-- Tor process alive? (if Tor enabled)        [REAL TRIGGER]
        |       |-- No: Immediate restart (>60s cooldown, then skip rest of Tor checks)
        |
        +-- Tor restart signal file? (/tmp/tor-restart-requested)   [REAL TRIGGER]
        |       |-- Yes: Immediate Tor restart (~30s)
        |       |-- Created by TransportUtilityService on SOCKS5 peer-send failure
        |
        +-- Descriptor publication stalled? (if Tor enabled)        [REAL TRIGGER]
        |       |-- No HS_DESC upload in 2h while circuits are up
        |       |-- Yes: Restart Tor to force republication (300s cooldown)
        |
        +-- Tor self-reachability check (every 5 minutes)           [ADVISORY ONLY]
                |-- Curl own .onion via SOCKS5 proxy (5 retries)
                |-- Failure: log degraded/recovered, NEVER restarts
                |-- (all Tor restarts go through one helper: fix HS dir perms,
                |    restart, relaunch descriptor watcher; max 5, reset after quiet)

Tor restart authority (three real triggers, plus an advisory self-check):

A Tor restart only happens for a signal that actually correlates with being unreachable by peers:

  1. Tor process death — checked directly each cycle; restarts immediately.
  2. Peer-send SOCKS5 failure — TransportUtilityService::send() writes /tmp/tor-restart-requested when a SOCKS5 send to a contact fails, and the watchdog acts within ~30 seconds (its normal cycle), far faster than waiting for a periodic check.
  3. Descriptor-publication stall — the descriptor watcher (watch_hs_descriptor_publication) now stays subscribed to Tor’s control port for the life of the daemon and refreshes a publication-timestamp marker (/tmp/tor-hs-published) on every HS_DESC upload (the initial publish plus Tor’s roughly-hourly republish). If Tor is up with established circuits but no descriptor has been uploaded in 2 hours, publication is stuck and Tor is restarted to force a fresh publish. The watcher records its PID so the watchdog relaunches it (in monitor mode) if it ever dies without a restart.

The self-reachability self-check (curl our own .onion through the local SOCKS5 proxy) is advisory only: it logs a degraded or recovered transition but never restarts Tor. Connecting to one’s own v3 onion requires a fresh rendezvous circuit and is unreliable, so it produces false negatives even when peers can reach the node perfectly well; using it as a restart trigger caused a restart loop on healthy nodes. The Tor restart counter resets after a quiet period so the bounded retry budget cannot get permanently stuck.

Shutdown Flag Lifecycle:

The shutdown flag (/tmp/eiou_shutdown.flag) coordinates between the PHP CLI commands and the bash watchdog:

Event Action
eiou shutdown Creates flag, sends SIGTERM to processors, cleans PID files
Watchdog cycle Checks for flag — if present, skips all processor checks
eiou start Removes flag
Watchdog (after flag removal) Detects transition, resets restart counters, resumes monitoring
Container startup (startup.sh) Removes stale flag from previous container lifecycle
Docker SIGTERM (container stop) graceful_shutdown() creates flag before stopping processors

Watchdog Configuration:

Setting Value
Check Interval 30 seconds
Restart Cooldown 60 seconds
Max Restarts 10 per processor
Tor Check Interval 300 seconds (5 minutes)
Tor Restart Cooldown 300 seconds
Tor Max Restarts 5

Message Delivery & Dead Letter Queue

The MessageDeliveryService provides reliable message delivery with automatic retries and exponential backoff. When all retries are exhausted, messages move to the Dead Letter Queue (DLQ) for manual intervention.

Retry Policy:

Parameter Value Constant
Max retries 5 (6 total attempts) DELIVERY_MAX_RETRIES
Base delay 2 seconds DELIVERY_BASE_DELAY_SECONDS
Jitter factor ±20% DELIVERY_JITTER_FACTOR
Max delay cap 300 seconds (5 min) MAX_RETRY_DELAY_SECONDS

Exponential backoff formula:

delay = baseDelay × 2^retryCount × (1 ± jitterFactor × random)
delay = min(delay, 300s)
Attempt Base Delay With Jitter (±20%)
1 2s 1.6–2.4s
2 4s 3.2–4.8s
3 8s 6.4–9.6s
4 16s 12.8–19.2s
5 32s 25.6–38.4s
6 (final) 64s 51.2–76.8s

Total worst-case retry window: ~2.5 minutes before DLQ entry.

Special cases:

  • Tor cooldown (TOR_COOLDOWN): When TorCircuitHealth marks an address as cooled down, the delivery is deferred without consuming a retry attempt. The message re-enters the queue and is retried after the cooldown period expires.
  • Rejected messages: Messages receiving an explicit rejection response are NOT retried — the rejection is final and the message is recorded as failed.

Dead Letter Queue flow:

Message send attempt
     |
     +-- Success? → done
     |
     +-- Failure → retry with backoff
     |      |
     |      +-- Retry 1..5 → attempt again
     |      |
     |      +-- All retries exhausted
     |             |
     |             v
     |      +-------------+
     |      | Dead Letter |
     |      | Queue       |
     |      +------+------+
     |             |
     +-------------+-------------+
     |             |             |
     v             v             v
  Retry       Abandon       Resolve
 (manual)   (give up)    (auto on success)

DLQ operations:

Operation Method Description
returnToPending Manual retry Returns message to pending queue for reprocessing
markAbandoned Give up Marks message as permanently failed with optional reason
markResolved Auto/manual Marks message as successfully reprocessed

DLQ background processing: processRetryQueue() uses atomic claiming (claimForRetry()) to prevent parallel workers from processing the same DLQ entry. Claimed entries are re-sent through the normal delivery pipeline.

Transaction DLQ payload refresh: Messages can sit in the DLQ for hours or days. For message_type='transaction' entries, the chain may have advanced (new outbound txs) or had a tx drop re-wire the link past a missing transaction since the original send — replaying the stored payload verbatim would ship a stale previousTxid. MessageDeliveryService::retryFromDlq therefore runs a refresh step before the send callback: it looks up the current chain head via getPreviousTxid, rewrites the payload’s previousTxid and time to current values, updates the transactions table and the DLQ row’s stored payload, and lets the transport layer re-sign on the send. After a successful retry, the freshly captured signature and nonce are persisted back to the transactions table so later sync responses serve a signature that still verifies against the row’s current fields. Both the GUI paths (DlqController::handleRetry, handleRetryAll) and the CLI path (CliDlqService::retryDlqItem) go through retryFromDlq so neither can drift from the other.

Distributed Locking

The DatabaseLockingService uses MariaDB advisory locks (GET_LOCK/RELEASE_LOCK) to coordinate exclusive access between concurrent PHP workers.

Parameter Value
Lock timeout 30 seconds (DB_LOCK_TIMEOUT_SECONDS)
Lock prefix eiou_
Scope Per-connection (auto-released on disconnect)

Where distributed locks are used:

Operation Purpose
Message delivery retry Prevents processRetryQueue() from claiming a delivery that another worker is actively retrying during its backoff sleep window
Transaction claiming Atomic PENDING → SENDING transition prevents two workers from processing the same outgoing transaction simultaneously

Atomic claiming pattern (used throughout the codebase):

// TransactionRecoveryRepository::claimPendingTransaction()
// Returns true only if this worker wins the race
UPDATE transactions SET status = 'sending'
WHERE txid = ? AND status = 'pending'
// rowCount() == 1 → claimed; 0 → another worker got it first

This pattern is used by TransactionProcessingService for both direct transactions (processOutgoingDirect) and P2P transactions (processP2pTransaction). It eliminates double-sends without requiring external lock infrastructure.


Data Layer

Database Tables

Each node maintains a MariaDB database with these primary tables:

Table Purpose
contacts Known peers with public keys, addresses, status
addresses Contact address variants (HTTP, HTTPS, Tor)
balances Current balance with each contact
debug Debug log entries and diagnostics
transactions Transaction history and chain links
p2p Outbound P2P routing messages
rp2p Return P2P (response) messages
message_delivery Delivery tracking with retry state
dead_letter_queue Failed messages for manual review
delivery_metrics Message delivery statistics
held_transactions Transactions pending sync completion
api_keys API authentication keys
api_request_log API request audit trail
payback_methods The node’s own payback methods — per-row AES-256-GCM encrypted encrypted_fields JSON blob keyed to the wallet with method_id as AAD (so ciphertext can’t be swapped between rows). Carries type, label, currency, priority, share_policy (ENUM auto/never), settlement_min_unit + settlement_min_unit_exponent. Rail-type dispatch is plugin-extensible via PaybackMethodTypeRegistry; see docs//docs/reference/plugins
payback_methods_received Methods received from contacts over the E2E payback-methods-request.v1 round-trip, written by ReceivedPaybackMethodService::handleIncomingResponse(). Carries contact_pubkey_hash, remote_method_id, type, label, currency, fields_json (decrypted plaintext at write time; MariaDB TDE handles at-rest protection), settlement_min_unit + settlement_min_unit_exponent, priority, received_at, expires_at (TTL for re-fetch), revoked_at. The contact-modal Payback tab does a live fetch on open and the Pay button POSTs the decrypted fields to paybackOptionsBuildPaymentUri for URI / QR synthesis via PaymentUriBuilderService
rate_limits Rate limiting state
chain_drop_proposals Mutual tx drop agreement tracking
p2p_senders Multi-path upstream sender tracking for RP2P forwarding
p2p_relayed_contacts Contacts that returned already_relayed during P2P broadcast (used by two-phase relay selection in best-fee mode)
rp2p_candidates Best-fee RP2P candidate responses awaiting selection
contact_credit Per-contact, per-currency available credit received from pong (UNIQUE on pubkey_hash, currency)
contact_currencies Per-contact, per-currency config (fee, credit limit) with direction tracking (incoming/outgoing = who initiated the relationship)
capacity_reservations Credit reserved at each relay hop during P2P routing (base_amount and total_amount including fees), status: active/released/committed
route_cancellations Audit trail for route cancellation messages sent to unselected P2P candidates after best-fee selection
payment_requests Live payment request lifecycle: pending + recently-resolved rows. Resolved rows older than paymentRequestsArchiveRetentionDays move to payment_requests_archive via the nightly archival cron
payment_requests_archive Cold storage for resolved payment requests. Schema mirrors payment_requests + an archived_at timestamp. Read paths (getResolvedHistoryPage, searchResolvedHistory, getByRequestId) UNION across live + archive transparently; pending reads never touch the archive. Created at schema version 9 (fresh installs via DatabaseSetup.php, existing nodes via runMigrations()). Pilot for transactions_archive below — establishes the batch-move, UNION-shim, and split-backup patterns on a table without chain semantics
transactions_archive Cold storage for completed transactions older than transactionsArchiveRetentionDays AND belonging to a bilateral pair that verified gap-free at the moment of archival. Schema mirrors transactions + an archived_at timestamp. Populated by TransactionArchivalService (cron at 01:30 UTC) — pairs with a detected chain gap are skipped, not archived. Created at schema version 10. See Archive-aware read paths below for how the chain walk, balances, statistics, and sync protocol all consult both tables
transaction_chain_checkpoints Per-bilateral-pair metadata recording the gap-free-at-archival proof. One row per {user_public_key_hash, contact_public_key_hash} (canonicalized LEAST/GREATEST so direction doesn’t matter). Columns: archived_count, archived_txid_hash (SHA-256 over the sorted archived-txid list — tamper detection), highest_archived_timestamp, highest_archived_time, last_verified_gap_free_at. Written by TransactionArchivalService::upsertCheckpointAfterMove after a successful batch; archived_count + archived_txid_hash are absolute values (recomputed from the archive each time) so the checkpoint is self-healing. Read path: TransactionChainRepository::verifyChainIntegrityByHashes() (called on every outbound send via the public-key variant) consults this row in the default useCheckpoint=true mode — a missing previous_txid from live is trusted as an archived row if a checkpoint exists, collapsing verify from O(all history) to O(live tail) + 1 indexed row lookup. ChainAuditService (invoked by eiou verify-chain) is the safety net: it calls verify with useCheckpoint=false (walks live + archive end-to-end) and additionally recomputes each pair’s archived_txid_hash against the stored value, detecting any post-archival tampering that the hot path trusts past by design

Archive-aware read paths

With an archive in place, every code path that asks a yes/no or SUM/COUNT question about transactions must consider both tables or risk treating archived history as if it vanished. Hot paths take a fast route via the per-pair checkpoint; other paths query both tables directly.

Hot path — verifyChainIntegrity on every outbound send: live query + indexed checkpoint lookup, O(live tail) + 1 row. Missing previous_txid from live is trusted as archived iff a checkpoint exists. Set $useCheckpoint=false to force the paranoid walk (both tables fully scanned + hash recomputed) — used by eiou verify-chain.

Non-hot paths UNION across both tables: getChainStateSummary (sync negotiation needs the complete txid list, not just a count), getContactBalance / getAllContactBalances (per-contact balances survive archival), getSentUserTransactions / getReceivedUserTransactions and the *Address variants (CLI eiou history output), getTransactionsByType (API GET /api/transactions?type=X), and every aggregate in TransactionStatisticsRepository (overall stats use a UNION ALL subquery so COUNT(DISTINCT sender_address) / COUNT(DISTINCT receiver_address) deduplicate naturally across the combined set).

Sync protocol lookups consult both tables to prevent counterparty re-sync from inflating our live table with txids we’ve already archived. Four methods:

Call site Method Why archive lookup is needed
Sync dedup (incoming) TransactionRepository::transactionExistsTxid Short-circuit on live hit; fall through to archive on miss so we don’t re-insert archived txids a counterparty still has in their live
Sync response (outgoing) TransactionRepository::getByTxid An archived tx a remote is missing must be returned so we can re-push it — a null return would silently drop it from the sync response
Peer status inquiry TransactionRepository::getStatusByTxid Archive rows are status='completed' by construction — answering TransactionNotFound for an archived tx would make the peer think it evaporated
Chain conflict detection TransactionChainRepository::getLocalTransactionByPreviousTxid A remote tx claiming previous_txid = X where X is archived is still a conflict; the archive-hit row is tagged with _source='archive' so resolveChainConflict can apply the archive-wins rule

All fall back defensively with a PDOException catch — during the v9→v10 migration moment where transactions_archive doesn’t exist yet, the live-only path is correct and the archive check silently contributes nothing.

Archive-wins rule for chain conflicts: narrow edge case where a remote tx arrives via sync claiming previous_txid = X, and we have an archived local tx also claiming previous_txid = X. getLocalTransactionByPreviousTxid returns the archived partner tagged with _source='archive'. SyncService::resolveChainConflict recognises the sentinel and forces winner=local, bypassing the usual lexicographic txid tiebreak. The remote tx is still inserted into live (both have valid signatures; chain ordering resolves at read time via the deterministic tiebreak) — only the resignLocalTransaction path is skipped, so the archive is never modified. A WARNING log entry captures the occurrence for operator investigation. The edge case itself shouldn’t happen under normal operation (archival moves only completed rows; chain conflicts happen between pending txs), but the rule makes stale-pending-tx / misconfigured-counterparty / adversarial scenarios safe.

Amount Storage: SplitAmount

All monetary amounts are stored as two BIGINT columns (_whole and _frac) instead of a single integer. This is implemented by the SplitAmount value object (/src/core/SplitAmount.php).

Storage format:

  • _whole: integer part (e.g., 1234 for $1,234.56)
  • _frac: fractional part × 10^8 (e.g., 56000000 for .56)
  • Maximum representable amount: PHP_INT_MAX.99999999 (~9.2 quintillion)
  • TRANSACTION_MAX_AMOUNT (PHP_INT_MAX / 4) enforced at input to leave headroom for multi-hop fee accumulation

Tables using split columns:

Table Column pairs
transactions amount_whole/_frac, my_fee_amount_whole/_frac, rp2p_amount_whole/_frac
balances received_whole/_frac, sent_whole/_frac
contact_currencies credit_limit_whole/_frac, credit_floor_whole/_frac
contact_credit available_credit_whole/_frac
p2p amount_whole/_frac, my_fee_amount_whole/_frac, rp2p_amount_whole/_frac
rp2p amount_whole/_frac, fee_amount_whole/_frac
capacity_reservations base_amount_whole/_frac, total_amount_whole/_frac

Factory methods:

  • SplitAmount::fromString($str) — precision-safe parsing via string operations (preferred for user input)
  • SplitAmount::fromMajorUnits($float) — float conversion (adequate for amounts < 10^15)
  • SplitAmount::from($value) — universal factory accepting string, float, int, array, or SplitAmount
  • SplitAmount::fromDbRow($row, $prefix) — extracts {prefix}_whole/{prefix}_frac from a database row

Arithmetic: add(), subtract(), multiplyPercent(), mulDiv() use bcmath strings internally — no intermediate PHP integer can overflow regardless of amount size.

Input validation: All validators (validateAmount, validateAmountFee, validateFeeAmount, validateCreditLimit) use bcmath string operations (bccomp/bcadd) and return decimal strings at 8-decimal precision. Display decimals (DISPLAY_DECIMALS) only affect UI formatting, not input acceptance.

Repository Pattern

Each table has a corresponding repository class extending AbstractRepository:

AbstractRepository
    |
    +-- AddressRepository
    +-- BalanceRepository
    +-- ContactRepository
    +-- TransactionRepository (core CRUD operations)
    |       |
    |       +-- TransactionStatisticsRepository (aggregations, statistics)
    |       +-- TransactionChainRepository (chain navigation, conflict resolution)
    |       +-- TransactionRecoveryRepository (stuck transaction recovery)
    |       +-- TransactionContactRepository (contact-based queries)
    +-- P2pRepository
    +-- P2pSenderRepository
    +-- P2pRelayedContactRepository
    +-- Rp2pRepository
    +-- Rp2pCandidateRepository
    +-- ContactCreditRepository
    +-- MessageDeliveryRepository
    +-- DeadLetterQueueRepository
    +-- DeliveryMetricsRepository
    +-- HeldTransactionRepository
    +-- ChainDropProposalRepository
    +-- ApiKeyRepository
    +-- DebugRepository
    +-- RateLimiterRepository

Transaction Repository Specialization:

The transaction data access layer is split into specialized repositories for maintainability:

Repository Responsibility
TransactionRepository Core CRUD, basic queries, transaction creation
TransactionStatisticsRepository Balance aggregations, transaction counts, statistics
TransactionChainRepository Chain traversal, prev_txid lookups, conflict resolution
TransactionRecoveryRepository Finding/updating stuck transactions for recovery
TransactionContactRepository Queries filtered by contact relationships

All specialized repositories use the QueryBuilder trait for shared query building functionality.

Supporting Classes:

Class Location Purpose
TransactionFormatter /src/formatters/ Output formatting for CLI and API responses
QueryBuilder /src/database/traits/ Shared SQL building and parameter handling

AbstractRepository Features:

Feature Description
PDO Injection Accepts PDO via constructor or creates from config
Prepared Statements All queries use parameterized statements
Transaction Support Begin/commit/rollback helpers
Error Handling Exceptions logged via Logger

Repository Access:

$container = ServiceContainer::getInstance();
$contactRepo = $container->getContactRepository();
$contacts = $contactRepo->getAcceptedContacts();

QueryBuilder Trait

The QueryBuilder trait (/src/database/traits/QueryBuilder.php) provides shared query building utilities used across repositories to reduce code duplication:

use Eiou\Database\Traits\QueryBuilder;

class MyRepository extends AbstractRepository
{
    use QueryBuilder;
}

Available Methods:

Method Purpose
createPlaceholders($values) Generate PDO placeholder string (?, ?, ?) for IN clauses
buildInClauseParams($values, $repeatCount, $additionalParams) Build parameters for queries with multiple IN clauses
getUserAddressesOrNull() Get user addresses or null if empty (early return pattern)
executeSelectAll($query, $params) Execute query returning all rows as associative array
executeSelectOne($query, $params) Execute query returning single row or null
buildUserTransactionQuery(...) Build user transaction query with address filtering
buildInClause($values) Build complete IN clause like “IN (?,?,?)”
buildWhereClause($conditions) Build WHERE clause from array of conditions
buildOrderByClause($columns) Build ORDER BY clause with direction support

Usage Examples:

// Create placeholders for IN clause
$placeholders = $this->createPlaceholders($userIds);
// Result: "?,?,?" for 3 items

// Build complete IN clause
$inClause = $this->buildInClause($addresses);
// Result: "IN (?,?,?)"

// Build WHERE clause from conditions
$where = $this->buildWhereClause([
    'status' => 'active',           // "status = ?"
    'amount >' => 100,              // "amount > ?"
    'sender_address IN (?,?)'       // Raw SQL (numeric key)
]);
// Result: "status = ? AND amount > ? AND sender_address IN (?,?)"

// Build ORDER BY clause
$orderBy = $this->buildOrderByClause([
    'timestamp' => 'DESC',
    'id'                            // Defaults to ASC
]);
// Result: "timestamp DESC, id"

// Execute queries with helper methods
$results = $this->executeSelectAll($query, $params);
$row = $this->executeSelectOne($query, $params);

This trait centralizes common query patterns to reduce code duplication across repositories.

Utils Infrastructure

The /src/utils/ directory provides cross-cutting infrastructure used throughout the application:

Utility Purpose
Logger File-based logging with severity levels, log rotation, and context tagging
SecureLogger Security-aware logger that redacts sensitive data (keys, mnemonics, auth codes) from log output
AdaptivePoller Dynamic polling interval adjustment based on workload — ramps down to min interval when busy, ramps up to max interval when idle; used by all background processors
AddressValidator Validates and normalizes node addresses (HTTP, HTTPS, Tor .onion formats)
InputValidator General-purpose input sanitization and validation for CLI and API inputs
Security Cryptographic helpers — message signing, signature verification, hash generation using secp256k1 ECDSA
SecureSeedphraseDisplay Secure terminal output for seed phrases — clears screen, displays temporarily, handles clipboard-safe formatting
TorCircuitHealth Per-.onion address failure tracking with configurable cooldown. File-based in /tmp/tor-circuit-health/ (shared across workers, clears on restart). Prevents repeated timeouts to unreachable hidden services. Integrated into TransportUtilityService::send() for automatic skip and optional HTTPS fallback (controlled by torFallbackRequireEncrypted — defaults to HTTPS-only, never plain HTTP). Fresh-onion grace (#868): never-reached onions get a configurable publication window (torFreshOnionGraceSeconds, default 600s) during which failures log “still publishing” and don’t count toward the cooldown threshold; suggestedTimeoutSeconds() returns a longer per-request timeout (torFreshOnionTimeoutSeconds, default 60s) so a single longer wait replaces multiple short timeouts wasting Tor circuits while the HS descriptor publishes. Once ever_succeeded=true is persisted, normal counting resumes for the rest of the wallet’s lifetime

P2P Networking

P2P Routing Overview

P2P routing enables transactions to reach recipients through intermediate nodes when no direct connection exists. The system supports two routing modes:

Mode Flag Internal Behavior
Fast (default) None fast=1 First RP2P response wins; lowest latency
Best-Fee (experimental) --best fast=0 Collects all responses, selects lowest accumulated fee (forced to fast for Tor unless EIOU_TOR_FORCE_FAST=false)
      ALICE                   BOB                    CAROL                   EVE
   (Sender)              (Intermediary)          (Intermediary)          (Recipient)
      |                       |                       |                       |
      |   P2P Request         |                       |                       |
      |  level=201            |                       |                       |
      |  max=207              |                       |                       |
      |---------------------->|                       |                       |
      |                       |   P2P Request         |                       |
      |                       |  level=202            |                       |
      |                       |  max=207              |                       |
      |                       |---------------------->|                       |
      |                       |                       |   P2P Request         |
      |                       |                       |  level=203            |
      |                       |                       |  max=207              |
      |                       |                       |---------------------->|
      |                       |                       |                       |
      |                       |                       |  RP2P Response        |
      |                       |                       |  (accepted)           |
      |                       |                       |<----------------------|
      |                       |  RP2P Response        |                       |
      |                       |  (propagating)        |                       |
      |                       |<----------------------|                       |
      |  RP2P Response        |                       |                       |
      |  (completed)          |                       |                       |
      |<----------------------|                       |                       |

Dead-End Cascade Cancel

When a P2P reaches a dead end (no route to recipient), cancel notifications propagate back upstream, freeing resources through the entire chain:

      ALICE                   BOB                    CAROL                DEAD END
   (Sender)              (Relay)                (Relay)              (No contacts)
      |                       |                       |                       |
      |   P2P Request         |                       |                       |
      |  (fast=0, --best)     |                       |                       |
      |---------------------->|                       |                       |
      |                       |   P2P Request         |                       |
      |                       |---------------------->|                       |
      |                       |                       |   P2P Request         |
      |                       |                       |---------------------->|
      |                       |                       |                       |
      |                       |                       |   (no contacts to     |
      |                       |                       |    forward — cancel)  |
      |                       |                       |                       |
      |                       |                       |  Cancel Notification  |
      |                       |                       |<----------------------|
      |                       |                       |                       |
      |                       |  Cancel Notification  | (P2P cancelled,       |
      |                       |  (all contacts        |  reservation released)|
      |                       |  responded/cancelled) |                       |
      |                       |<----------------------|                       |
      |                       |                       |                       |
      |  Cancel Notification  | (P2P cancelled,       |                       |
      |  (P2P status →        |  reservation released)|                       |
      |   cancelled)          |                       |                       |
      |<----------------------|                       |                       |
      |                       |                       |                       |
  [P2P resolved: cancelled, ~5-10s instead of 300s expiration timeout]

Cancel propagation modes:

Mode Trigger Behavior
Dead-end cancel No contacts to broadcast to, or all contacts responded sendCancelNotificationForHash() notifies upstream sender
Best-fee unselected After best-fee selection picks cheapest route cancelUnselectedRoutes() sends partial route_cancel to unselected candidates (multi-route safe)
Full cancel Originator reject (p2p reject) or complete cascade broadcastFullCancelForHash() sends route_cancel with full_cancel=true to all contacts, propagating downstream

Request Level Randomization

The request level is randomized to prevent network traffic analysis:

// Constants for randomization
P2P_MIN_REQUEST_LEVEL_RANGE_LOW = 300
P2P_MIN_REQUEST_LEVEL_RANGE_HIGH = 700
P2P_MIN_REQUEST_LEVEL_RANDOM_LOW = 200
P2P_MIN_REQUEST_LEVEL_RANDOM_HIGH = 500
P2P_MIN_REQUEST_LEVEL_RANDOM_OFFSET_LOW = 1
P2P_MIN_REQUEST_LEVEL_RANDOM_OFFSET_HIGH = 10

// Formula
level = abs(rand(300,700) - rand(200,500)) + rand(1,10)

This produces unpredictable but bounded values, preventing attackers from correlating request patterns.

Hop Budget Randomization

The hop budget controls how many relay hops a P2P request can traverse. It is set once by the originator as maxRequestLevel = minRequestLevel + hopBudget.

// Computed by RouteCancellationService::computeHopBudget(minHops, maxHops)
$maxP2pLevel = $user->getMaxP2pLevel();                // default: 6
$minHops = max(1, floor($maxP2pLevel * HOP_BUDGET_MIN_RATIO)); // default ratio 0.5 → 3
$hopBudget = computeHopBudget($minHops, $maxP2pLevel); // range: [3, 6]

// Geometric distribution (30% stop probability per hop beyond minHops):
//   3 hops: 30%
//   4 hops: 21%
//   5 hops: 15%
//   6 hops: 34% (remainder)

Key properties:

  • Only set on the originator — relays inherit maxRequestLevel unchanged
  • HOP_BUDGET_MIN_RATIO (default: 0.5) prevents uselessly low budgets (1 hop = direct contacts only)
  • Controlled by the hopBudgetRandomized user setting (GUI/CLI/API toggle, default: enabled). When disabled, returns maxP2pLevel for full routing depth — recommended for sparse trust graphs where reachability matters more than traffic analysis resistance. Also overridable via EIOU_HOP_BUDGET_RANDOMIZED env variable (env takes precedence)
  • Dead-end behavior: when requestLevel >= maxRequestLevel, the relay stores as cancelled and sends sendCancelNotificationForHash() upstream immediately

P2P Message Flow

Outbound (P2pService):

  1. User initiates transaction to unknown recipient
  2. P2pService creates P2P record with randomized level and status queued
  3. P2pMessageProcessor daemon picks up queued messages (polls every 100ms–5s)
  4. Coalesce delay groups concurrent P2Ps into a single mega-batch (2000ms window)
  5. Mega-batch broadcasts to accepted contacts that support the transaction’s currency via sendMultiBatch() (curl_multi)
  6. Concurrency-limited sliding window caps simultaneous connections per protocol
  7. Each relay node queues, coalesces, and broadcasts to its own contacts (level++)
  8. Process continues until recipient found or level exceeds maxRequestLevel

Coalesce Delay & Mega-Batch:

When the P2pMessageProcessor picks up queued P2P messages, it applies a coalesce delay (P2P_QUEUE_COALESCE_MS = 2000ms) before sending. If fewer messages than the batch size are queued, it waits 2 seconds for additional P2Ps to accumulate, then sends them all in a single sendMultiBatch() call.

P2P queued (T=0)  ─┐
P2P queued (T=0.5) ─┤── coalesce (2s) ──> mega-batch send via curl_multi
P2P queued (T=1.2) ─┘

The mega-batch collects all sends from all queued P2Ps into a flat array keyed by {p2pHash}|{contactAddress}. A single curl_multi call sends everything in parallel (with per-protocol concurrency limits). Results are mapped back to individual P2Ps using the compound key.

Why coalesce: Without it, each P2P would trigger its own broadcast cycle. When multiple contacts send P2P requests through the same relay simultaneously, coalescing reduces the number of curl_multi rounds and TCP connection setups.

When mega-batch is NOT used: Direct contact matches (recipient is a contact of the relay) are handled inline without batching — the P2P is resolved immediately.

Inbound Response (Rp2pService):

  1. Recipient receives P2P request
  2. Recipient sends RP2P response back along route
  3. Intermediaries forward RP2P (reverse path)
  4. Original sender receives acceptance
  5. Transaction sent along the established route

Routing Modes

Fast Mode (Default)

Fast mode processes the first RP2P response immediately. When a node receives an RP2P in fast mode, Rp2pService::checkRp2pPossible() calls handleRp2pRequest() directly. No candidate storage or selection logic is involved.

Characteristics:

  • Lowest latency — uses first successful route
  • May not select the cheapest fee route
  • Single transaction path, no waiting
  • Status flow: initial → queued → sent → found → completed
  • Forced for Tor recipients: When the destination address is .onion, fast mode is automatically enforced regardless of the --best flag, because best-fee mode generates excessive relay traffic and Tor’s ~5s/hop latency amplifies the wait overhead. Enforced on both sender side (prepareP2pRequestData) and receiver side (handleP2pRequest) to prevent remote nodes from forcing best-fee over Tor. Can be disabled via EIOU_TOR_FORCE_FAST=false env variable for testing.
  • Rejection counting: When handleRp2pRequest() returns false (fee too high or relay can’t afford), checkRp2pPossible() increments contacts_responded_count for the sender. When all contacts have responded (all rejected or cancelled), the node cancels the P2P immediately and propagates cancel upstream via sendCancelNotificationForHash(), avoiding a wasted wait until expiration.

Best-Fee Mode (Experimental)

Best-fee mode collects RP2P responses from all paths and selects the route with the lowest accumulated fee. Enabled with the --best CLI flag or the GUI checkbox.

Phase 1 — Request Broadcasting:

The P2P request is broadcast to all accepted contacts with fast=0 and a hopWait value that controls per-hop expiration timing. Each relay stores the request and tracks how many contacts it forwarded to (contacts_sent_count).

Phase 2 — Candidate Collection:

When an RP2P response arrives at a node in best-fee mode, it is stored as a candidate in the rp2p_candidates table rather than being processed immediately. The node atomically increments contacts_responded_count on the P2P record.

// In Rp2pService::checkRp2pPossible()
if ($fast == 0) {
    handleRp2pCandidate();  // Store candidate, don't process yet
} else {
    handleRp2pRequest();    // Process immediately (fast mode)
}

Phase 3 — Best-Fee Selection:

Selection is triggered when either:

  1. All contacts have responded (contacts_responded_count >= contacts_sent_count)
  2. The per-hop expiration fires (orphaned candidate recovery via CleanupService)

The best candidate is the one with the lowest amount (since accumulated fees increase the final amount):

SELECT * FROM rp2p_candidates WHERE hash = ? ORDER BY amount ASC

After selection, selectAndForwardBestRp2p() iterates through candidates from cheapest to most expensive and calls handleRp2pRequest() for each one. If a candidate fails validation (fee exceeds originator’s maxFee, or relay node can’t afford the amount), the next candidate is tried. If all candidates fail, the P2P is cancelled and a cancel notification is sent upstream. All candidates are deleted after the loop completes regardless of outcome.

After successful selection, RouteCancellationService::cancelUnselectedRoutes() sends route_cancel messages to all unselected candidates’ contacts, releasing their capacity reservations immediately rather than waiting for CleanupService TTL expiry. Each cancellation is recorded in the route_cancellations audit table. Receiving nodes acknowledge the partial route_cancel without cancelling their own P2P or releasing reservations — this is safe for diamond topologies where a node may be part of both selected and unselected routes.

When the originator rejects a P2P (via CLI p2p reject or API), a full cancel is broadcast downstream via P2pService::broadcastFullCancelForHash(). This sends route_cancel with full_cancel=true to all accepted contacts. Relay nodes receiving a full cancel: (1) mark their local P2P as cancelled, (2) release their capacity reservation, and (3) propagate the full cancel further downstream to their own contacts — creating a cascade that frees resources through the entire route chain. CleanupService TTL expiry remains as a natural fallback.

Phase 3a — Two-Phase Relay Selection (Mesh Deadlock Prevention):

In mesh topologies, two relay nodes may be contacts of each other (e.g., A2 and A4). Both receive the P2P from upstream, broadcast to their downstream contacts, and record each other as already_relayed in the p2p_relayed_contacts table. Neither can complete selection without the other’s best candidate — a deadlock.

The two-phase relay mechanism breaks this deadlock:

  1. Relay Phase 1 — When all inserted contacts have responded but relayed contacts haven’t, the node sends its current best downstream candidate to all already_relayed contacts via sendBestCandidateToRelayedContacts(). The phase1_sent flag is set atomically before sending to prevent re-triggering. If no candidates exist (all inserted contacts cancelled), sendCancelToRelayedContacts() sends cancel notifications instead, so relayed contacts can count the response and break mutual deadlocks without waiting for hop-wait expiration.

  2. Relay Phase 2 — When all propagated contacts (inserted + relayed combined) have responded, selectAndForwardBestRp2p() picks the overall best candidate (which now includes candidates from relayed contacts) and forwards it upstream via handleRp2pRequest().

  A1 (upstream)          A3 (upstream)
   |                      |
  A2 ←-- already_relayed --→ A4
   |                          |
  A5 (downstream)            A6 (downstream)

Timeline (candidates exist):
1. A5 responds to A2, A6 responds to A4  (inserted contacts done)
2. A2 sends best(A5) to A4              (relay Phase 1)
   A4 sends best(A6) to A2              (relay Phase 1)
3. A2 re-selects from {A5, A4's candidate}, sends upstream to A1  (relay Phase 2)
   A4 re-selects from {A6, A2's candidate}, sends upstream to A3  (relay Phase 2)

Timeline (all inserted cancelled — no candidates):
1. A5 cancels to A2, A6 cancels to A4   (inserted contacts done, zero candidates)
2. A2 sends cancel to A4                (relay Phase 1 cancel)
   A4 sends cancel to A2                (relay Phase 1 cancel)
3. A2 + A4 trigger Phase 2 → selectAndForwardBestRp2p → cancel + propagate upstream

Race condition guard: If a relayed contact’s RP2P arrives before all inserted contacts respond, relay Phase 2 can trigger directly (skipping relay Phase 1). To prevent this, selectAndForwardBestRp2p() checks phase1_sent before forwarding upstream. If Phase 1 was skipped, it calls sendBestCandidateToRelayedContacts() first, ensuring the relayed contact receives the node’s best downstream candidate before the final selection goes upstream.

Queued status guard: When a relay node receives a P2P, it is inserted with status 'queued'. The P2P daemon picks it up and forwards it to downstream contacts, updating the status and contacts_sent_count. However, cancel notifications or RP2P candidates from other paths can arrive at the node before the daemon processes the queued P2P. If handleCancelNotification() runs while contacts_sent_count is still 0, it sees respondedCount >= sentCount and triggers selection prematurely — cancelling the P2P before it reaches the destination.

Both handleCancelNotification() and handleRp2pCandidate() check the P2P status and defer selection when it is 'queued'. The response is still counted (incrementing contacts_responded_count), but the selection trigger is skipped. After the daemon forwards the queued P2P and sets contacts_sent_count, the subsequent checkBestFeeSelection() call picks up the deferred responses and triggers selection with the correct counts.

For matched-contact sends (destination node is a direct contact of the relay), processQueuedP2pMessages() also tracks contacts_sent_count for 'found' responses and calls checkBestFeeSelection() after forwarding — ensuring RP2P responses that arrived during the blocking send are processed.

Sender list merge: handleRp2pRequest() merges p2p_relayed_contacts into the p2p_senders list before sending RP2P responses. This ensures that contacts which returned already_relayed during broadcast — but whose own P2P hasn’t arrived at this node yet — still receive the RP2P response.

Phase 4 — Cascading Selection:

Per-hop expiration ensures selection cascades from leaf nodes back to the originator. Leaf nodes (closest to recipient) expire first, forward their best route upstream, and each level selects its best before its own expiration fires.

Per-Hop Expiration

In best-fee mode, each relay node calculates a local expiration based on its position in the route, ensuring leaves expire before upstream nodes.

Calculation at originator:

$hopWait = floor($fullExpiration / $maxRoutingLevel) - P2P_HOP_PROCESSING_BUFFER_SECONDS;
$hopWait = max($hopWait, P2P_MIN_HOP_WAIT_SECONDS);  // Minimum 3 seconds

Calculation at relay:

$remainingHops = max(1, $maxRequestLevel - $requestLevel);
$scaledWait = $hopWait * $remainingHops;
$scaledExpiration = now() + $scaledWait;

Upstream expiration cap:

The scaled calculation can produce expirations that exceed the originator’s timeout (e.g., 15s * 19 hops = 285s vs a 60s originator expiration). When this happens, the originator expires first, finds zero candidates (relays are still alive), and kills the P2P — defeating best-fee routing entirely.

To prevent this, each relay caps its expiration to the upstream node’s expiration minus one hopWait buffer. This preserves the leaf-to-root cascade ordering while guaranteeing no relay outlives its upstream:

$upstreamExpiration = $request['expiration'];  // Incoming from upstream node
if ($upstreamExpiration > 0 && $scaledExpiration >= $upstreamExpiration) {
    $cappedExpiration = $upstreamExpiration - convertToMicrotime($hopWait);
    $minExpiration = now() + convertToMicrotime(P2P_HOP_PROCESSING_BUFFER_SECONDS);
    $localExpiration = max($minExpiration, $cappedExpiration);
} else {
    $localExpiration = $scaledExpiration;
}

Example cascade (60s originator expiration, hopWait = 15s, 4 relay levels):

A0 (Originator):  expiration = T+60   (set by user's p2pExpiration)
A1 (Relay 1):     min(T+75, T+60-15) = T+45   ← capped by upstream
A3 (Relay 2):     min(T+60, T+45-15) = T+30   ← capped by upstream
A6 (Relay 3):     min(T+45, T+30-15) = T+15   ← capped by upstream

Leaves expire first → select best candidate → RP2P propagates upstream → each level selects its best → originator selects at T+60. The cascade completes within the originator’s window regardless of hop count or maxRequestLevel.

Orphaned Candidate Recovery

If a P2P expires before all contacts respond, the CleanupService triggers best-fee selection on the available candidates rather than expiring the P2P:

// In CleanupService::expireMessage()
if (!$message['fast'] && $candidateCount > 0) {
    $this->rp2pService->selectAndForwardBestRp2p($hash);
    $this->p2pRepository->updateStatus($hash, 'found');  // Prevent re-processing
    return;  // Don't expire — forward best available route
}

After selection, the P2P status is set to 'found'. This is critical because getExpiredP2p() runs on every cleanup cycle — without this status update, the same P2P would be re-processed on the next cycle, potentially interfering with the in-progress best-fee delivery. The getExpiredP2p() query excludes 'found' alongside 'completed', 'expired', and 'cancelled'.

This ensures that even partial responses produce a usable route.

Credit Reservation Lifecycle

When a relay node accepts and forwards a P2P request, it creates a capacity reservation to track the credit being held for that route. This prevents the relay from over-committing credit across concurrent P2P requests.

Creation — When P2pService::handleP2pRequest() processes an incoming P2P:

$baseAmount = (int) $request['amount'];                    // Original amount without relay fee
$totalAmount = $this->calculateRequestedAmount($request);  // Amount + relay fee
$this->capacityReservationRepository->createReservation(
    $request['hash'], $senderPubkeyHash, $baseAmount, $totalAmount, $currency
);
// Status: 'active'
Field Description Example
hash P2P request identifier abc123...
base_amount Original requested amount (no relay fees) 1000
total_amount Amount + this relay’s fee (what the relay owes upstream) 1020
contact_pubkey_hash SHA-256 of upstream sender’s public key de7f...
currency Transaction currency VWL
status Reservation state: active → released or committed active

How reservations affect available credit: Reservations are tracked separately from the contact_credit table. Available credit is exchanged via ping/pong (see Ping/Pong Credit Exchange). When validating whether a relay can afford a P2P, the system checks balance and credit limit against the request amount. Active reservations can be queried via getTotalReservedForPubkey() to see total committed capacity per contact.

Three release paths:

                    +--------+
                    | active |
                    +---+----+
                        |
          +-------------+-------------+
          |             |             |
          v             v             v
    +-----------+  +-----------+  +-----------+
    | released  |  | committed |  | released  |
    | (cancel)  |  | (success) |  | (expired) |
    +-----------+  +-----------+  +-----------+
          |             |             |
          +-------------+-------------+
                        |
                        v
                  (deleted after 7 days)
Path Trigger Method Status
Cancel Route not selected (best-fee), upstream cancel, or dead-end releaseByHashAndContact() released (reason: cancelled)
Commit Transaction successfully sent along this route commitByHash() committed (reason: committed)
Expiry P2P expires before completion (CleanupService) releaseByHash() released (reason: expired)
Cleanup Released/committed records older than 7 days deleteOldRecords(7) Deleted permanently

Fee Accumulation Through Relays

Fee calculation uses a multiplicative (compounding) model: each relay charges its fee on the accumulated total (including all downstream relay fees), not on the original base amount. This means fees compound through the chain — a relay charging 2% on $101 (which already includes $1 of downstream fees) charges $2.02, not $2.00.

Fee calculation happens in two phases:

Phase 1 — Outbound P2P (fee pre-calculation / estimate):

When a relay receives a P2P, P2pService::calculateRequestedAmount() computes a fee estimate based on $request['amount'] (the original base amount, unchanged during outbound propagation). This estimate is stored as my_fee_amount for capacity reservation purposes but is not authoritative — the actual fee is recalculated during the RP2P return when the accumulated downstream total is known.

// P2pService::calculateRequestedAmount() — outbound P2P (estimate only)
$feeAmount = calculateFee($request['amount'], $feePercent, $minimumFee);
// Stored in DB as my_fee_amount (estimate); P2P forwarded with original amount

Phase 2 — Inbound RP2P (authoritative fee on accumulated total):

When the RP2P response returns from the recipient, each relay recalculates its fee on the accumulated RP2P amount (which includes all downstream relay fees). The exact rounded fee is saved to my_fee_amount, replacing the Phase 1 estimate, and then added to the RP2P amount before forwarding upstream. This ensures TransactionService::removeTransactionFee() subtracts the identical value — preventing rounding discrepancies.

// Rp2pService::handleRp2pRequest() — inbound RP2P (authoritative)
$recalculatedFee = calculateFeeForP2p($p2p, $request['amount']);  // fee on accumulated total
updateFeeAmount($hash, $recalculatedFee);  // save exact rounded fee to DB
$request['amount'] += $recalculatedFee;    // add to accumulated total

// Per-sender relay back (each sender may have a different fee rate):
$baseAmount = $request['amount'] - $recalculatedFee;  // accumulated downstream total
$senderFee = calculateFee($baseAmount, $perSenderFeePercent, $minimumFee);
$senderRequest['amount'] = $baseAmount + $senderFee;

Result: Fees are multiplicative/compounding. Each relay’s fee is calculated on the accumulated total (base + all downstream fees), producing a compound effect through the chain.

Example — $100 VWL through 3 relay paths:

Originator: "Send $100 to Eve"
     |
     +----> Route A: Bob (2%) → Carol (1%) → Eve
     |        Eve responds: $100
     |        Carol adds 1% of $100.00 = $1.00 → $101.00
     |        Bob adds 2% of $101.00 = $2.02 → $103.02
     |
     +----> Route B: Dan (3%) → Eve
     |        Eve responds: $100
     |        Dan adds 3% of $100.00 = $3.00 → $103.00
     |
     +----> Route C: Frank (0.5%) → Grace (0.5%) → Eve
              Eve responds: $100
              Grace adds 0.5% of $100.00 = $0.50 → $100.50
              Frank adds 0.5% of $100.50 = $0.50 → $101.00

RP2P candidates at originator (sorted by amount ASC):
  Route C: $101.00 ← selected (cheapest)
  Route B: $103.00
  Route A: $103.02  (compounding makes multi-hop slightly more expensive)

RP2P candidate storage (rp2p_candidates table):

Field Description
hash P2P request identifier
amount Total cost through this route (base + all relay fees)
fee_amount This relay’s fee contribution
sender_public_key Upstream contact’s public key
sender_address Upstream contact’s address
sender_signature Cryptographic proof of route

Selection: ORDER BY amount ASC — cheapest route wins. If the cheapest fails validation (relay can’t afford or fee exceeds originator’s maxFee), the next cheapest is tried. All candidates are deleted after selection regardless of outcome.

Zero-fee relaying: Both the contact fee percentage and the system minimum fee (minFee) can be set to 0, enabling completely free relaying. This is safe because fees are excluded from hash/txid generation (hashes use sender pubkey, receiver pubkey, amount, currency, and time — never fees), all division operations involving fees have zero guards, and balance updates use the final amount which simply equals the base amount when fee is 0. Since fees are configured per contact, operators can relay free for friends and family while charging fees for other contacts. Setting minFee to 0 removes the global fee floor, so a 0% contact fee truly results in zero relay cost. Both defaults ship at 0 — the default contact fee (CONTACT_DEFAULT_FEE_PERCENT) and minFee — so the protocol charges no routing fee out of the box and any fee is a deliberate per-contact choice the operator opts into. A non-zero minFee is an opt-in floor for the case where an operator does set a small fee: a tiny percentage (e.g. 0.01%) on a very small amount can otherwise round below 1e-8 and yield 0.

Multi-Path Sender Tracking

In a mesh network, a relay node may receive the same P2P request from multiple upstream nodes (e.g., A1 and A3 both relay to A4). The p2p_senders table tracks all upstream senders so RP2P responses can be forwarded back along every path that delivered the P2P.

  A0 (originator)
   |  \
  A1   A2
   |    |
  A3---A4 (relay)    ← A4 receives P2P from both A1 and A3
         |
        A5 (recipient)

How it works:

  1. When A4 first receives the P2P from A1, it stores the P2P record (p2p.sender_address = A1) and records A1 in p2p_senders.
  2. When A4 receives the duplicate P2P from A3, it returns already_relayed but also records A3 in p2p_senders.
  3. When A4 receives the RP2P from A5, it forwards the response to all senders in p2p_senders (both A1 and A3), not just the original sender.

Sender address correction on transaction arrival:

When the actual transaction arrives at A4, it may come from A3 (the route A0 chose) rather than A1 (stored in p2p.sender_address). The TransactionProcessingService detects this mismatch and updates p2p.sender_address to match the actual sender. This ensures completion relay, cleanup recovery, and txid bookkeeping reference the correct upstream node.

Transport Modes

Mode URL Pattern Use Case
HTTP http://hostname Local testing only
HTTPS https://hostname Production with SSL
Tor http://xxx.onion Anonymous communication

Priority: Tor > HTTPS > HTTP (security preference)

Transport Fallback: When TOR delivery fails during contact requests (SOCKS5 connection error), TransportUtilityService::send() attempts HTTP/HTTPS delivery using stored alternative addresses. This fallback is only enabled for contact creation/acceptance messages (allowTransportFallback parameter) — transactions and other messages respect the user’s chosen transport to preserve privacy. The fallback looks up alternative addresses via AddressRepository::getContactPubkeyHash() and lookupByPubkeyHash().

Transport Concurrency Control

Batch sends (sendBatch(), sendMultiBatch()) use curl_multi with a sliding-window concurrency limit to prevent overwhelming network circuits, particularly Tor. Instead of firing all connections at once, the executeWithConcurrencyLimit() method runs up to N handles simultaneously and adds the next as each completes.

Limits are configured per protocol in Constants::CURL_MULTI_MAX_CONCURRENT:

Protocol Max Concurrent Rationale
HTTP 10 Fast connections, high throughput
HTTPS 10 Same as HTTP with TLS overhead
Tor 5 SOCKS5 circuits overload easily; lower limit prevents thundering herd

When a batch contains mixed protocols, the most restrictive (lowest) limit is used. Unknown protocols fall back to the lowest configured value.

The lookup is centralized in TransportUtilityService::getConcurrencyLimit(array $addresses) which resolves addresses to protocols via determineTransportType() and returns min() of the applicable limits. To tune: edit the CURL_MULTI_MAX_CONCURRENT array in Constants.php.

P2P Inquiry Token Authentication

The P2P completion inquiry flow requires the original sender (A) to contact the end-recipient (C) directly to deliver the transaction description. Since C is not A’s contact, C must verify that the inquiry sender is the legitimate P2P originator — not a relay node attempting to forge the inquiry.

Threat: Relay nodes possess the P2P hash, salt, and time. The address-based hash check (resolveUserAddressForTransport) converts any sender address to the local node’s address, meaning any node with the P2P data could pass validation.

Solution: Hash-committed inquiry secret.

CREATION (on originator A):
  inquiry_secret = random_bytes(32)                    ← stored only on A
  salt           = sha256(inquiry_secret)              ← the commitment; propagates through relays
  p2p_hash       = sha256(receiver + salt + time)

The field the code calls salt is the inquiry-secret commitment — there is no separate inquiry_token value, in the payload or the hash. It is baked into the P2P hash that every node validates, so a relay cannot swap it without breaking the hash, which every downstream node would reject.

VERIFICATION (on end-recipient C):
  1. A sends completion inquiry to C with inquiry_secret
  2. C computes: sha256(received_secret) === stored salt
  3. Match → A is the legitimate originator; accept inquiry + store description
     No match → reject (relay node attempting to forge inquiry)

Key properties:

  • inquiry_secret never traverses the relay chain — only sent on the direct A→C path
  • salt (the commitment sha256(inquiry_secret)) propagates openly but is irreversible
  • Swapping the salt breaks the P2P hash, detected by every relay node
  • The transport envelope is signed, preventing man-in-the-middle modification of the inquiry
  • The secret is ephemeral — used once for the initial inquiry. After the description is stored on all nodes, it is recoverable via normal transaction chain sync (A syncs with B to recover descriptions). The end-recipient does not need to store the secret.

Database columns (p2p table):

Column Stored On Purpose
salt All nodes sha256(inquiry_secret) — the commitment, baked into the P2P hash (there is no separate inquiry_token column)
inquiry_secret Originator only Pre-image for initial inquiry authentication

Blinded-destination address matching (and why the “oracle” is not a vulnerability)

TL;DR for reviewers. A node recognising itself as the destination of a P2P request — even a probe with attacker-chosen salt/time — is the routing layer working as designed. It has been flagged as an “address oracle” more than once and reworked/reverted; before changing it, read this whole section, including the two accepted nuances at the end (relay enumeration and cross-transport locator linkage) and why restricting the match to the arrival transport is not an option. A naive fix breaks cross-network routing. The matchYourselfP2P / matchContact docblocks in P2pService.php link here.

How destination matching works. A P2P request never names its destination in clear — that would tell every relay who is transacting with whom. Instead it carries a blinded tag. The exact production formula is three components:

inquiry_secret = random_bytes(32)            ← stored only on the originator
salt           = sha256(inquiry_secret)      ← the inquiry-secret commitment; travels in the request
hash           = sha256(destinationAddress · salt · time)

salt doubles as the inquiry-secret commitment (see “P2P Inquiry Token Authentication” above) — there is no separate inquiry_token component in the hash, in the payload, or in the p2p schema. An implementation must compute exactly sha256(destinationAddress · salt · time) or existing nodes will not recognise its requests.

Each node along the route checks “is this me?” by recomputing the hash over its own address(es) and comparing (matchYourselfP2P). A match means “I am the destination”; the node then replies with an RP2P back toward the sender. A relay that is not the destination is not handed the destination address — but note (see the enumeration point below) the blinding is plaintext-disclosure resistance, not anonymity against an enumerating relay.

Why a matching probe reveals little that matters. To construct the hash you must already possess the address you are testing, and you only send the probe to a node whose address you already hold. A “that is me” confirms only something the querier already supplied. Knowing an address is itself the trust primitive — a contact gives you their address so you can pay them; confirming “yes, that address is me” to someone who already has it is the point, not a leak.

Why it does not enable useful social-graph deanonymization. Suppose an attacker knows address X belongs to person X and probes through a peer B:

  • The response carries no hop distance. A match tells the attacker only that B is on a path to X — not that B knows X directly. A-B-X and A-B-C-X are indistinguishable from the response alone.
  • The only signal that could reveal distance is fee accumulation (send a real payment and watch the fees), and it does not reliably reveal distance either. Total accumulated fee is a sum, so different-length paths collide: A-B-C-X and A-B-C-D-X are indistinguishable whenever their totals happen to match, and any zero-fee hop makes a longer path look identical to a shorter one. So even a paying attacker cannot place X in the graph.

So the worst an attacker learns is “someone in the network routes to an address I already knew,” with no reliable path or distance information — the privacy property a relay/onion routing layer is supposed to provide.

Two honest nuances (deliberately accepted).

  1. A relay can enumerate candidate addresses it already knows. Every relay receives salt, time, and the unkeyed hash, so a relay can recompute sha256(candidate · salt · time) for any address it holds — exactly what matchContact() does for its own contacts. This is plaintext-disclosure resistance, not destination anonymity against candidate enumeration: a relay never sees the destination address in the request, but it can test addresses it already has. In this system that is acceptable — your relays are your trusted contacts, and testing an address you were already given tells you nothing you did not already know.

  2. Cross-transport locator linkage. matchYourselfP2P() checks every locator from getUserLocaters() (HTTP, Tor, …), not just the address the request arrived on. This is required for routing: a request’s blinded destination is built from whatever address the originator had for the destination, which may be a different transport than the hop it arrives over. A consequence is that someone who holds two candidate addresses (say a clearnet address and a suspected .onion) can send a probe to one while hashing the other and confirm they belong to the same node — and isNotBlocked() lets an unknown key do this without being a contact. In this system’s trust model that is accepted: a node’s locators are exchanged with contacts when the contact is established, so its own addresses are not treated as mutually unlinkable secrets. A deployment that specifically needs a clearnet endpoint to be unlinkable from a Tor endpoint against a non-contact who already holds both does not get that here; the only mechanism that would provide it is the keyed/asymmetric destination tag below, and restricting the match to the arrival transport is not an option because it breaks cross-network routing. The simpler operator-side answer already exists: run the node Tor-only (no EIOU_HOST / clearnet locator). With a single onion locator there is no second address to link, so this linkage does not exist for that deployment at all.

What the code actually guards (robustness, not privacy). blindedDestinationFields() rejects a request whose salt/time are absent, empty, or non-scalar. This is not an attempt to close an oracle — a well-formed probe is meant to match. It exists because PHP concatenates a missing array key as "", which would collapse the compare to sha256(address) === suppliedHash (a match decision driven by malformed input) and would feed null salt/time into the p2p row insert, crashing on its NOT NULL columns. Every legitimate P2P carries salt and time from the payload builder, so rejecting malformed ones is free. The digest comparison uses hash_equals() for constant-time hygiene. matchContact() applies the same malformed-input guard before doing any contact lookup.

Do not “fix” this by: requiring the sender to be a known contact (relays are arbitrary — this breaks routing), suppressing the RP2P response (that is the routing mechanism), or trying to make responses uniform (the funds/relay behaviour legitimately differs). If a future protocol revision ever wants stronger unlinkability, the only real lever is a keyed/asymmetric destination tag that only the true originator can construct — a wire-format change with cross-version-compatibility implications, not a validation tweak.


Transaction Lifecycle

Transaction States

                                +----------+
                                |  pending |
                                +----+-----+
                                     |
                                     v
                                +----------+
                                | sending  |
                                +----+-----+
                                     |
               +---------------------+---------------------+
               |                     |                     |
               v                     v                     v
         +----------+          +----------+          +----------+
         |   sent   |          | rejected |          |  failed  |
         +----+-----+          +----------+          +----------+
               |
               +---------------------+
               |                     |
               v                     v
         +----------+          +-----------+
         | accepted |          | cancelled |
         +----+-----+          +-----------+
               |
               v
         +----------+
         |completed |
         +----------+

State Descriptions

State Description
pending Transaction created, awaiting processing
sending TransactionProcessor is attempting delivery
sent Message delivered, awaiting recipient response
accepted Recipient acknowledged receipt
completed Transaction finalized, balances updated
rejected Recipient declined (various reasons)
cancelled Not received by peer in time (timeout)
failed Delivery failed after max retries

Chain Integrity

Transactions form a chain linked by prev_txid:

+--------+     +--------+     +--------+     +--------+
| TX #1  |<----| TX #2  |<----| TX #3  |<----| TX #4  |
|prev=   |     |prev=#1 |     |prev=#2 |     |prev=#3 |
| null   |     |        |     |        |     |        |
+--------+     +--------+     +--------+     +--------+

Both parties maintain their own view of the chain. The ContactStatusProcessor validates chain consistency and triggers sync if discrepancies are found.

Only completed/accepted/pending transactions participate in the chain. TransactionRepository::getPreviousTxid and getPreviousTxidsByCurrency explicitly filter out cancelled and rejected rows when picking the previous_txid for a new outbound transaction. This mirrors the sync layer, which never ships cancelled/rejected rows to the peer — so both sides agree on “last transaction” and a new tx’s signed previous_txid always points at something the peer has. Cancelled rows remain as purely local history entries with no successors pointing at them; they’re visible in the local tx list but invisible to the chain walk, to sync, and to new-tx linking. This closes a class of permanent chain gap where a cancel-while-pending on the sender would leave the peer unable to verify any subsequent transaction.

Self-healing on failed delivery (core strength)

A transaction that never reaches the recipient — whether the send times out, the transport is down, or all retries exhaust and the user Abandons the DLQ entry — does not create a sync gap. The mechanism is deliberately asymmetric between the two sides:

  • Sender side: keeps the failed tx as a local cancelled row for audit and accounting. The row has a valid previous_txid pointing into the chain but nothing ever points at it — it’s a leaf, not a link. The sender’s chain walker (TransactionChainRepository::getTransactionChain) filters status NOT IN ('cancelled', 'rejected'), so the chain walk skips right past it. verifyChainIntegrity only inspects completed/accepted/paid, so the cancelled row doesn’t trip the verifier either. valid_chain stays true on the sender.
  • Recipient side: never heard of the tx at all, so there’s nothing to reconcile. The next tx the recipient DOES receive already has a rewritten previous_txid pointing at the last completed predecessor — a direct link across the gap the cancelled tx left behind on the sender’s side. valid_chain stays true on the recipient too.

The DLQ retry path actively participates in this self-healing. MessageDeliveryService::refreshTransactionDlqPayload (files/src/services/MessageDeliveryService.php) re-queries getPreviousTxid before every retry and rewrites the pending tx’s previousTxid to the current chain head. If intervening cancelled siblings accumulated while the retry was queued, the rewritten payload points past them. If a retry eventually succeeds, the delivered tx’s previous_txid reflects the chain as it existed at delivery time — not at original-send time. If the retry is abandoned, the dormant payload’s previousTxid is irrelevant because nothing references its txid.

Concrete example

Three consecutive sends on Alice’s side: TX_A completes, TX_B never reaches Bob and ends up cancelled, TX_C is sent afterwards. Abbreviated txids for readability:

Alice's DB:
  TX_A   prev=TX_0   completed     ← last known-good before the run
  TX_B   prev=TX_A   CANCELLED     ← leaf: signed pointing at TX_A, but
                                     nothing ever points back at TX_B
  TX_C   prev=TX_A   completed     ← skipped past TX_B on send; its
                                     previous_txid references TX_A directly,
                                     because getPreviousTxid() filters out
                                     cancelled rows when picking the parent

Bob's DB (same window):
  TX_A   prev=TX_0   completed
  TX_C   prev=TX_A   completed     ← links cleanly to TX_A; Bob has no
                                     record of TX_B at all (COUNT = 0)

The key detail: TX_C on Alice’s side has previous_txid = TX_A, NOT TX_B. The cancelled TX_B is a dangling leaf — it points backwards into the chain but no subsequent row points at it. Both sides’ chain walks start from the same head and follow the same TX_C → TX_A → TX_0 path. Both sides’ valid_chain = true.

User-facing consequence: Abandoning a stuck-in-DLQ transaction is safe — it’s a bookkeeping action, not a chain surgery. No tx drop proposal is needed. The “Failed Messages” queue exists to tell the user the send didn’t land; once they’ve acknowledged that (Abandon) or the retry eventually succeeded (auto-resolve on tx completion — see DeadLetterQueueRepository::markResolvedByTxid), there is nothing else for the chain-integrity subsystem to do.

When a chain gap is detected during sync, the SyncService attempts backup recovery before falling through to a tx drop:

  1. Local self-repair — checks local database backups for missing transactions
  2. Remote backup request — sends remaining missing txids to the contact via the missingTxids field in the sync request; the contact checks its DB and backups
  3. Tx drop fallback — only if neither side has the missing transactions in any backup, the ChainDropService coordinates mutual agreement to drop the missing transaction(s) and re-wire the chain around the drop. A single tx drop spans one or more consecutive missing transactions; non-consecutive gaps require a separate tx drop per run

Send Flow (SendOperationService)

When a user sends a transaction, chain integrity is verified before the transaction is created. If the chain has gaps, sync (with backup recovery) is attempted automatically. The transaction only proceeds once the chain is valid:

sendEiou()
  +-- Validate inputs (address, amount, currency, etc.)
  +-- verifySenderChainAndSync()
  |     +-- verifyChainIntegrity() -> if valid, return success
  |     +-- syncTransactionChain()
  |     |     +-- Local backup recovery (self-repair)
  |     |     +-- Sync with contact (+ missingTxids for remote backup recovery)
  |     |     +-- Post-sync chain integrity check
  |     +-- Re-verify chain after sync
  |     +-- Return success if chain repaired, failure if gaps remain
  |
  +-- If chain verification failed:
  |     +-- Auto-propose tx drop (if sync completed but gaps remain)
  |     +-- Return error to user (transaction NOT created)
  |
  +-- If chain valid -> prepareStandardTransactionData() (tx created here)
  +-- Send transaction to contact

The transaction is never created or held during chain repair — if backup recovery or sync repairs the chain, the send proceeds immediately. If the chain cannot be repaired, the user receives an error and no transaction is created.

HeldTransactionService

When a transaction arrives with an unknown prev_txid, it cannot be immediately processed because the chain is incomplete.

Incoming Transaction (prev_txid=unknown)
              |
              v
+---------------------------+
| HeldTransactionService    |
|                           |
| 1. Store in held_trans    |
| 2. Request chain sync     |
| 3. Wait for missing TX    |
| 4. Process held when      |
|    chain complete         |
+---------------------------+

P2P-aware lifecycle: P2P transactions have a fixed expiration timestamp set at creation (P2P_DEFAULT_EXPIRATION_SECONDS = 300). Every relay node in the P2P chain holds an independent copy with the same expiration. Setting a local transaction back to “pending” does not extend the P2P lifetime on other nodes. Therefore:

  • Proactive hold skips P2P if insufficient lifetime: Before holding a P2P transaction during sync, processOutgoingP2p checks the remaining P2P lifetime. If less than HELD_TX_SYNC_TIMEOUT_SECONDS remains, the hold is skipped because the P2P will expire on every other relay node before sync completes.
  • Resume checks actual expiration timestamp: isP2pExpiredOrCancelled checks both the P2P status field AND the raw expiration timestamp (the cleanup cycle may not have updated the status yet).
  • Stale sync timeout < P2P expiration: HELD_TX_SYNC_TIMEOUT_SECONDS (120s) is intentionally shorter than P2P_DEFAULT_EXPIRATION_SECONDS (300s) so stale syncs are failed before the P2P network-wide expiry window closes.
  • Standard direct transactions are the primary beneficiary of hold-and-resume since there are no multi-hop expiration constraints.

Receive Flow (Incoming Transaction)

When a node receives a standard direct transaction from a contact, the request passes through validation, chain integrity checks, storage, and acceptance response:

HTTP Request (from sender)
  |
  +-- index.html (Entry Point)
  |     +-- Verify envelope signature (secp256k1)
  |     +-- Validate required fields (senderPublicKey, senderAddress)
  |     +-- Validate public key and address format
  |     +-- Route by type === "send"
  |
  +-- TransactionValidationService.checkTransactionPossible()
  |     +-- Is sender blocked? -> reject with 'contact_blocked'
  |     +-- checkPreviousTxid()
  |     |     +-- Get expected: getPreviousTxid(senderPubkey, receiverPubkey)
  |     |     +-- Compare with request['previousTxid']
  |     |     +-- Mismatch? -> Proactive sync with sender
  |     |           +-- syncTransactionChain() (with backup recovery)
  |     |           +-- Retry previousTxid check after sync
  |     +-- checkAvailableFundsTransaction()
  |     |     +-- (balance + credit_limit) >= amount? -> reject if not
  |     +-- Check for duplicate txid -> reject if exists
  |     +-- Generate recipient signature
  |
  +-- TransactionProcessingService.processStandardIncoming()
  |     +-- INSERT transaction with status='received'
  |     +-- UPDATE tracking fields (initial_sender, end_recipient)
  |
  +-- Response: Echo acceptance JSON with recipientSignature

The proactive sync in the checkPreviousTxid step uses the same backup recovery mechanism described in the Chain Integrity section: local backup check, then remote backup request via missingTxids, then tx drop as last resort.

Completion and Balance Idempotency

A transaction’s completion can arrive from more than one path (the message processor handling a completion message, and the cleanup poller reconciling a P2P), so applying the balance must be exactly-once. Completion is an atomic claim: TransactionRepository::claimCompletion() issues a single conditional UPDATE ... SET status = completed and the caller credits the balance only when that statement actually transitioned a row (rowCount() > 0). Two callers racing the same completion therefore credit it once, not twice.

The claim only transitions a row that is genuinely in progress; it excludes the terminal states (already-completed, plus cancelled, rejected, expired, and failed). A replayed or out-of-order completion message can no longer resurrect a finalized row to completed and credit it, which would have laundered a cancelled or rejected transaction into a paid one. The balances table additionally carries a uniqueness constraint per contact and currency, so duplicate rows cannot form and be double-counted by the sum-based balance reads.

Sync Flow (SyncService)

Transaction chain synchronization repairs chain gaps between two contacts. Sync can be triggered in two ways:

Type Trigger Blocking? Context
Proactive Chain mismatch during incoming transaction validation (checkPreviousTxid) Yes — blocks validation until sync completes Incoming transaction has unknown previousTxid; sync repairs chain, then transaction is re-validated and processed inline
Reactive Manual request (GUI/CLI/API), contact status ping, or send command No — runs independently User or ContactStatusProcessor initiates sync; SyncEvents::SYNC_COMPLETED event unblocks any held transactions

The flow includes bilateral backup recovery so both sides can self-repair in a single round trip:

syncTransactionChain(contactAddress, contactPublicKey)
  |
  +-- 1. Get lastKnownTxid (our latest tx with this contact)
  |
  +-- 2. Detect chain gaps and attempt local self-repair
  |     +-- verifyChainIntegrity() -> get list of gaps
  |     +-- If BackupService available:
  |     |     +-- For each missing txid: restoreTransactionFromBackup()
  |     |     +-- Re-verify chain, update lastKnownTxid and remaining gaps
  |     +-- Else: all gaps become missingTxids for remote to resolve
  |
  +-- 3. Build sync request
  |     +-- buildTransactionSyncRequest(contactAddress, contactPubkey, lastKnownTxid)
  |     +-- Append missingTxids[] (remaining gaps for remote to check)
  |
  +-- 4. Send request to contact -> contact's handleTransactionSyncRequest()
  |
  +-- 5. Process sync response
        +-- For each transaction in response:
        |     +-- Skip if already exists locally
        |     +-- Check for chain conflict (same previous_txid)
        |     |     +-- Deterministic resolution: lower txid wins
        |     +-- Ingest gate (untrusted sole-source row):
        |     |     +-- Verify sender signature
        |     |     +-- Verify recipient signature (zero-amount contact bypass only)
        |     |     +-- Reconcile E2E value fields (or flag sent-side row for review)
        |     |     +-- Bind to {this node, this contact}
        |     |     +-- Preserve peer status (refuse pending/sending; never force completed)
        |     +-- Insert transaction
        +-- Dispatch SYNC_COMPLETED event
        +-- HeldTransactionService processes any unblocked held transactions


handleTransactionSyncRequest(request)  [Contact's side]
  |
  +-- 1. Verify sender is known contact
  +-- 2. Get transactions newer than request's lastKnownTxid
  +-- 3. Filter and format via formatTransactionForSync()
  |     +-- Description privacy: only include description for 'contact' or 'standard' memo
  +-- 4. Check missingTxids[] from requester (cap at 10)
  |     +-- For each missing txid not already in response:
  |     |     +-- Check local DB -> if found, format and include
  |     |     +-- If BackupService available: check local backups -> if restored, format and include
  +-- 5. Return filtered transactions (oldest first)

Because a restoring node may have a single contact as the only source for the pair, the ingest gate in step 5 treats every row as untrusted. The full set of checks (signatures, value-field reconciliation, party binding, status preservation) and the proof-of-relationship rule for auto-accepting a restored contact are documented under Security Model → Restore and Re-Sync Integrity.

Tx Drop Agreement Flow

When neither side has the missing transactions in their database or backups, the tx drop protocol coordinates mutual agreement to drop the missing transaction(s) and re-wire the chain around the drop. A single tx drop handles one or more consecutive missing transactions in a single run; non-consecutive gaps require a separate proposal per run of consecutive missing txs:

      PROPOSER (A)                                     RECEIVER (B)
           |                                                |
  1. verifyChainIntegrity()                                 |
     -> gap detected, missing txid                          |
           |                                                |
  2. Sync attempted (with backup recovery)                  |
     -> neither side has it                                 |
           |                                                |
  3. proposeChainDrop(contactPubkeyHash)                    |
     +-- Backup recovery fallback (safety net)              |
     +-- Create proposal record (direction=outgoing)        |
     +-- Send proposal ------------------------------------>|
           |                                   4. handleIncomingProposal()
           |                                      +-- Verify gap exists locally
           |                                      +-- Backup recovery fallback
           |                                      +-- Store proposal (direction=incoming)
           |                                                |
           |                                   5. User reviews via CLI/GUI
           |                                                |
           |                             +------------------+------------------+
           |                           Accept                                Reject
           |                             |                                     |
           |                    6. acceptProposal()                      6r. rejectProposal()
           |                       +-- executeChainDrop()                    +-- Update status: rejected
           |                       |     +-- Relink broken_txid's            +-- Send rejection --+
           |                       |     +-- previous_txid to skip                        |
           |                       |     +-- Re-sign affected tx                          |
           |                       +-- syncContactBalance()                               |
           |                       +-- updateChainStatus(valid=true)                      |
           |                       +-- Update status: accepted                            |
           |                       +-- Send acceptance                                    |
           |                       |   + resigned txs                                     |
           |<----------------------+                                                      |
           |                                                                              |
  7. handleIncomingAcceptance()                                                           |
     +-- executeChainDrop() locally                                                       |
     +-- processResignedTransactions()                                                    |
     +-- syncContactBalance()                                                             |
     +-- updateChainStatus(valid=true)                                                    |
     +-- Update status: accepted                                                          |
     +-- Mark proposal executed                                                           |
     +-- Send acknowledgment + our resigned txs -------->|                                |
           |                                             |                                |
           |                          8. handleIncomingAcknowledgment()                   |
           |                             +-- processResignedTransactions()                |
           |                             +-- updateChainStatus(valid=true)                |
           |                             +-- Mark proposal fully executed                 |
           |                                                                              |
           |<-----------------------------------------------------------------------------+
           |
  7r. handleIncomingRejection()
      +-- Update status: rejected
      +-- Log rejection reason
           |
         (done -- gap remains unresolved)

Auto-Propose: The send command and ping (Check Status) both auto-propose a tx drop when sync detects mutual gaps. The ContactStatusService calls proposeChainDrop() after syncTransactionChain() returns with unresolved chain_gaps. Controlled by Constants::isAutoChainDropProposeEnabled() (env: EIOU_AUTO_CHAIN_DROP_PROPOSE, default: true).

Auto-Accept: Incoming proposals can be auto-accepted when Constants::isAutoChainDropAcceptEnabled() is true (env: EIOU_AUTO_CHAIN_DROP_ACCEPT, default: false for safety). A balance guard can optionally run before auto-accepting, controlled by Constants::isAutoChainDropAcceptGuardEnabled() (env: EIOU_AUTO_CHAIN_DROP_ACCEPT_GUARD, default: true). When the guard is enabled, it compares stored balances (from BalanceRepository) against balances calculated from existing transactions. If the missing transactions include net payments TO us (net_missing > 0), auto-accept is blocked and the proposal requires manual review. This prevents a malicious proposer from erasing debt by forcing a tx drop on transactions where they owed us money. When the guard is disabled (EIOU_AUTO_CHAIN_DROP_ACCEPT_GUARD=false), auto-accept proceeds unconditionally.

Post-Drop Actions: After successful execution, ChainDropService recalculates the contact balance (via SyncTriggerInterface::syncContactBalance()) and updates valid_chain in the contacts table so the GUI immediately reflects the repaired chain.

Proposal States: pending → accepted → executed (or rejected / expired / failed)

Contact Lifecycle

Contacts progress through states managed by the contacts table:

                              +----------+
     Contact request sent --> |  pending |
                              +----+-----+
                                   |
                      +------------+------------+
                      |                         |
                      v                         v
               +----------+             +-----------+
               | accepted |             |  blocked  |
               +----+-----+             +-----------+
                    |                         ^
                    |                         |
                    +--- eiou contact block --+
                    |
                    +--- eiou contact delete --> (row deleted from DB)
State Description
pending Contact request created, awaiting acceptance by other party
pending (prior contact) Auto-created by ContactStatusService when an unknown address pings after wallet restore; named RestoredContact<N>. If autoAcceptRestoredContact is enabled (default), auto-promotion to accepted with default fee/credit happens only when a completed value transaction already exists for the pair (the proof-of-relationship gate, see Security Model → Restore and Re-Sync Integrity); otherwise, or if the setting is disabled, it stays pending with a “Prior Contact” badge and the user must re-accept with name, fee, and credit limit
accepted Both parties confirmed; transactions and sync are enabled
blocked Contact blocked; incoming messages rejected

Online Status: Accepted contacts also have an online_status field updated by the ContactStatusProcessor: online, partial, offline, or unknown (default). The partial status indicates the node is reachable but has degraded message processors (some of P2P, Transaction, or Cleanup processors are not running). The pong response includes processorsRunning and processorsTotal fields for remote nodes to determine partial vs online status, chainStatusByCurrency for per-currency chain validation results, and availableCreditByCurrency for per-currency available credit. The processor pings one contact per cycle, validates chain integrity per currency, and triggers sync if any currency’s chain heads don’t match.

Node Identity Safety (Retire and Multi-Instance Detection)

A wallet’s seed deterministically derives its Tor .onion, so running the same seed on two nodes at once makes them fight over one hidden service (constant descriptor overwrite, so the node becomes unreachable) and splits inbound traffic across two divergent databases. Two mechanisms address this: a deliberate way to move an identity between nodes, and a passive detector for the accidental case.

Retire / unretire. eiou retire (or POST /api/v1/system/retire with {"confirm": true}) stops the processors and persists a retired flag, so on every boot the node skips hidden-service publication and the processors until eiou unretire. This lets an operator decommission a node before bringing the same seed up elsewhere. Restoring a seed prints a prominent warning to retire any other node first (acknowledged with EIOU_CONFIRM_SINGLE_NODE=1). Because retiring tears down the .onion, a node reached only over Tor must be reactivated via HTTP/HTTPS or the CLI. The teardown and republish run root-side via restart-poller markers, since neither the CLI (as www-data) nor the API can perform them directly; unretire republishes only when the wallet’s hidden-service key is actually absent (the state retiring leaves behind), so a stray unretire request on a healthy node is ignored.

Multi-instance detection. /api/health serves a per-boot instance_id that changes each start, and a node carries it in the pong it sends contacts. A peer watches the id a contact answers with across polls: if it alternates back and forth between two values within about fifteen minutes, the contact’s one seed-derived .onion is being served by two same-seed nodes in turn, and the contact is marked with an advisory multi_instance flag in the API and GUI. Keying on the flip-back-and-forth (rather than any id change) means an ordinary reboot (a single change to a fresh id) and a crash-looping node (a run of all-new ids) are not mistaken for a second node; the flag clears once the id is stable again. This peer-mediated multi_instance flagging is always on. A node cannot reliably detect the conflict about itself (self-rendezvous to one’s own .onion is too unreliable), so there is additionally a best-effort self-check that ships off by default and is enabled with EIOU_IDENTITY_MONITOR=on; the robust mitigation remains eiou retire.

Contact Request Flow:

  Node A                                        Node B
    |                                             |
    +-- eiou contact add <address> <name>         |
    |     +-- Send contact request -------------->|
    |         (tx_type='contact', amount=0,       +-- Contact appears as 'pending'
    |          currency=<requested currency>)     |
    |                                             |
    |                                             +-- eiou contact accept <hash> ...
    |                                             |     +-- Update contact to 'accepted'
    |                                             |     +-- Calculate available credit
    |<-- Send acceptance (+ availableCredit) -----+     +-- Complete contact transaction
    +-- Update contact to 'accepted'              |
    +-- Save B's available credit                 |
    +-- Calculate own available credit            |
    +-- Return ack (+ availableCredit) ---------->|
    +-- Complete contact transaction              +-- Save A's available credit

Both nodes start with accurate available credit immediately after acceptance, without waiting for the first ping/pong cycle. The credit data is included in the E2E-encrypted acceptance message and acknowledgment response. For new contacts, available credit equals the credit limit. For re-added contacts with prior transactions, it reflects the real balance: availableCredit = (sentBalance - receivedBalance) + creditLimit.

Ping/Pong Credit Exchange

The ContactStatusProcessor periodically pings accepted contacts (one per 5-minute cycle). The ping/pong exchange serves three purposes: online status detection, chain validation, and available credit synchronization.

Ping payload (sent by ContactStatusProcessor::pingContact()):

Field Description
receiverAddress Contact’s address
prevTxidsByCurrency Per-currency latest transaction chain heads for validation
requestSync Boolean flag requesting chain sync

Pong response (returned by recipient’s ping handler):

Field Description
status 'pong' (required)
processorsRunning Count of active background processors
processorsTotal Expected total processors
availableCreditByCurrency {currency: amount} — how much credit the contact has available for us
creditLimitByCurrency {currency: amount} — the raw per-currency credit limit the contact extends to us, independent of balance. Lets us check the credit they grant against any minimum we required, without assuming a zero balance the way available credit would. Older peers omit the field; the caller skips the check when absent (back-compat).
chainStatusByCurrency Per-currency chain validity flags
peerKnownCurrencies Currency codes the responder has visible to this peer — specifically, rows where status=‘accepted’ (any direction) OR (status=‘pending’ AND direction=‘incoming’). Outgoing-pending rows on the responder’s side are deliberately excluded so we don’t pre-announce requests that haven’t been delivered yet. Used by the caller to reconcile stale outgoing-pending rows the responder either declined silently or never received. Older peers omit the field; the caller skips reconciliation when absent (back-compat).

Available credit update: Available credit is explicitly reported by the remote contact and stored in the contact_credit table. There are three update paths:

  1. Ping/pong: The processor calls saveAvailableCreditFromPong() which upserts each currency’s credit via ContactCreditRepository::upsertAvailableCredit().
  2. Contact acceptance: The acceptance message and acknowledgment both include availableCreditByCurrency, saved via saveAvailableCreditFromAcceptance() and saveAvailableCreditFromResponse() respectively.
  3. Transaction completion: The completion response includes the sender’s available credit with a timestamp, saved via upsertAvailableCreditIfNewer().

Required-minimum check (sender side): A contact request can require its requested credit limit as a hard minimum. The recipient’s wallet enforces that at accept time and holds it as a durable floor against later edits (stored in contact_currencies.credit_floor_* on the incoming row). The requester gets the complementary, after-the-fact check: when it sends an enforced request, the minimum it required is stamped onto its outgoing row’s credit_floor_*, and on each pong checkRequestedMinimumFromPong() compares the contact’s creditLimitByCurrency against that floor. If the contact extends less than the required minimum, the outgoing row’s requested_min_unmet flag is set (and a warning logged); the flag clears once they grant at least the minimum. This catches a non-conforming or modified recipient that ignores the accept-time gate or lowers its credit limit afterward, cases the recipient-side enforcement structurally cannot cover. It is detection, not prevention: a contact’s extended credit is their own local setting and cannot be forced, so the value is surfacing the mismatch (a warning icon on the contact row, a notice in the contact’s Status tab, and requested_min_unmet / requested_minimum on the contacts API) rather than silently operating under an unwanted credit arrangement.

Chain validation and sync trigger: The processor compares prevTxidsByCurrency from the pong against local chain heads. If any currency’s chain heads don’t match, sync is triggered automatically. This is controlled by the contactStatusSyncOnPing setting (default: true). When disabled, chain mismatches are logged but not auto-repaired.

Currency-status reconciliation: After saving available credit, the caller compares its own (direction=outgoing, status=pending) rows against peerKnownCurrencies. Any currency missing from the peer’s list means the peer either declined the request and the notification was lost in flight, or never received it at all — the caller drops the stale row + rejects the contact transaction so the user’s next retry succeeds via addCurrencyToExisting rather than tripping CONTACT_EXISTS. This is a backstop for the primary path (the peer’s contact_currency_declined notification handled by MessageService::handleContactMessageRequest); reconcile catches lost messages.

Age guard. Reconcile only fires for rows older than the cleanupDeliveryRetentionDays setting (default: 30 days, sourced from UserContext::getCleanupDeliveryRetentionDays()). Rows younger than that may still have an in-flight delivery attempt — first-pass retry queue or DLQ — and prematurely dropping them would make the user give up on a request that’s about to land. After the retention window the underlying delivery record has been pruned by the cleanup processor, so a peer that doesn’t recognize the currency genuinely never will and reconcile is safe. Failure mode of the guard is “stale row hangs around longer than necessary” rather than “user loses a real request” — strictly the safer side. No hard-coded threshold; the operator can tune the window via changesettings cleanupDeliveryRetentionDays N.

Privacy scope of peerKnownCurrencies. Two layers of scoping:

  1. Filtered by pubkey_hash = <requesting peer> so it only contains currencies that already involve that specific peer. It cannot leak currencies you trade with other contacts, because those are stored under different pubkey-hashes.
  2. Filtered to status='accepted' OR (status='pending' AND direction='incoming') so the responder’s own outgoing-pending rows — requests they’re trying to send but haven’t successfully delivered yet — are excluded from the advertisement. Without this scope, an in-flight outgoing-pending row would tell the peer “I’m planning to ask you about X” before they receive the actual request, leaking intent ahead of the message.

The pong is only sent to accepted contacts (blocked / unknown peers get a buildRejection response with no per-currency data). Net effect: the peer is told the state for our pair that they already half-know from their own DB (their own request landed → they have the row → matching incoming-pending on our side); the proactive list just makes lost state-change messages reconcilable in O(1) ping cycles instead of hanging forever.

Online status determination:

Status Condition
online Pong received, all processors running
partial Pong received, but processorsRunning < processorsTotal
offline Ping failed (connection error, timeout)
unknown Never pinged (default)

Error Handling

Recovery Service:

Transactions stuck in sending state (e.g., after a crash) are recovered on startup:

// TransactionRecoveryService
RECOVERY_SENDING_TIMEOUT_SECONDS = 120   // Stuck after 2 minutes
RECOVERY_MAX_RETRY_COUNT = 3             // Max recovery attempts

Dead Letter Queue:

Messages that fail after all retries are moved to the dead letter queue for manual review rather than being silently dropped.


Startup Sequence

Container Startup (startup.sh)

1. Configure output buffering for real-time logging
         |
         v
2. Register signal handlers (SIGTERM, SIGINT, SIGHUP)
         |
         v
3. Generate or install SSL certificates (priority chain)
   - Check /ssl-certs/ for external certs
   - Check for Let's Encrypt (LETSENCRYPT_EMAIL env var → certbot)
   - Check /ssl-ca/ for CA-signed generation
   - Fall back to self-signed
         |
         v
4. Start services: cron -> tor -> nginx + php-fpm -> mariadb
   - The www PHP-FPM pool is configured to pass the environment-derived
     AppConfig settings through to its workers (EIOU_PUBLIC_PLUGIN_ROUTES,
     APP_DEBUG, TRUSTED_PROXIES, ...) so FPM-served requests read them
     consistently with the CLI. clear_env otherwise stays on (no secrets
     exposed).
   - EIOU_PUBLIC_PLUGIN_ROUTES is resolved to its literal ceiling
     (off/allow/on) and re-exported before that passthrough is written,
     so no later context applies the default itself. Matters for the
     "off" lock: an off that failed to reach the pool would have an
     FPM-context reconcile read unset, take the "allow" default, and
     render a locked node's public routes back on.
   - Plugin master key resolution runs before php-fpm starts so the FPM
     master process picks up EIOU_PLUGIN_MASTER_KEY in its env. The
     Debian init script that starts php-fpm strips parent-shell env,
     so startup.sh injects a literal `env[EIOU_PLUGIN_MASTER_KEY] =
     <hex>` directive into the www pool config (joining the existing
     AppConfig passthrough block), and PluginPoolService::renderPoolConfig
     emits the same form into each sandboxed plugin pool. The value is
     hex-encoded (64 chars for 32 bytes) because PHP-FPM pool config
     rejects '=' characters, which base64 padding would produce. Two
     modes:
     - Operator-supplied (Mode B): EIOU_PLUGIN_MASTER_KEY set in the
       container env (must be hex). The value never lands on the
       persistent config volume; it does land in pool config files
       on the container's image overlay.
     - Auto-generated (Mode A, default): if the env var is absent on
       first boot, startup.sh generates 32 random bytes via
       `openssl rand -hex 32` and persists to
       `/etc/eiou/config/plugin-master.key` (mode 0640, root:www-data,
       64 hex chars; www-data must read it to render plugin pool
       configs). Survives container restart through the config volume.
     Apart from this one ecosystem-wide passthrough, per-plugin pools
     keep their environment-free sandbox. See SECURITY.md for the
     threat-model comparison between Mode A and Mode B, and docs//docs/reference/plugins
     for the per-plugin HKDF derivation recipe.
         |
         v
5. Wait for MariaDB readiness (mysqladmin ping)
         |
         v
6. Wallet generation or restoration
   - RESTORE_FILE (file-based, most secure)
   - RESTORE (env var)
   - EIOU_HOST set (new wallet with HTTP/HTTPS hostname)
   - Default (new wallet, Tor only)
         |
         v
7. Restart Tor to load hidden service keys
   - Fix permissions on /var/lib/tor/hidden_service
   - Retry up to 3 times with exponential backoff
         |
         v
8. Validate message processing prerequisites (MessageCheck.php)
         |
         v
9. Wait for Tor connectivity (curl through SOCKS5 proxy)
   - Timeout: EIOU_TOR_TIMEOUT (default 120s)
         |
         v
10. Start background message processors
    - processors/P2pMessages.php
    - processors/TransactionMessages.php
    - processors/CleanupMessages.php
    - processors/ContactStatusMessages.php (if enabled)
         |
         v
11. Start watchdog for process and Tor hidden-service health monitoring
         |
         v
12. Synchronous plugin sandbox reconcile (`eiou plugin reconcile`)
    - Creates each enabled plugin's FPM pool socket and renders its nginx
      routes (admin, and the public customer route when the node ceiling
      permits it and that plugin's own toggle is on) before the node
      reports ready,
      closing the cold-boot window where the memory-backed pool sockets
      did not yet exist
         |
         v
13. Enter main loop (sleep + wait for signals)

Application Initialization Order

CRITICAL: The initialization order in Application::__construct() is essential:

1. getLogger()           -> Initialize Logger
         |
         v
2. Database Setup
   - constructDatabase() if first run
   - loadCurrentDatabase()
         |
         v
3. getDatabase()         -> Create PDO connection
         |
         v
4. runMigrations()       -> Add new tables if needed
         |
         v
5. If config/userconfig.json exists:
   - loadCurrentUser()   -> UserContext MUST load first
   - loadServiceContainer()
   - loadUtilityServiceContainer()
   - wireAllServices()   -> Wire circular dependencies
   - runTransactionRecovery() (CLI only)

Important: UserContext MUST initialize BEFORE ServiceContainer. Violating this order causes runtime crashes.


Docker Topologies

Four docker-compose files provide different network topologies for development and testing:

File Nodes Use Case
docker-compose-single.yml 1 (eiou-single) Basic development, startup validation
docker-compose-4line.yml 4 (alice, bob, carol, daniel) Linear P2P routing tests (~1.1GB)
docker-compose-10line.yml 10 (node-a through node-j) Extended routing, latency testing (~2.8GB)
docker-compose-cluster.yml 13 (cluster-a0 hub + spokes) Hub-and-spoke mesh topology

Single Node

+-------------+
| eiou-single |
+-------------+

Used for startup validation, wallet generation testing, and single-node API/CLI development. Minimal resource footprint.

4-Node Linear

alice <---> bob <---> carol <---> daniel

Each node is a direct contact of its neighbour. Tests P2P routing across 1-3 hops, transaction chain synchronization, and contact status monitoring.

10-Node Linear

node-a <-> node-b <-> node-c <-> ... <-> node-j

Extended linear topology for testing deeper routing paths, per-hop expiration cascading in best-fee mode, and network propagation delays.

Cluster (Hub-and-Spoke Mesh)

  a31 a32     a41 a42
    \  /       \  /
     a3         a4
       \       /
         a0
       /       \
     a2         a1
    /  \       /  \
  a22 a21   a12  a11

Hub node (cluster-a0) connects to spoke nodes (cluster-a1 through cluster-a4), each with their own leaf nodes. Tests multi-path routing, two-phase relay selection deadlock prevention, and multi-path sender tracking.

Container clock

Containers run in UTC by design. The base image leaves /etc/localtime symlinked to Etc/UTC, and the image build does not override it, so docker exec <node> date reports UTC regardless of the host’s timezone. Cron schedules (the analytics, backup, archival, and update-check jobs documented elsewhere in this file) and PHP-side log timestamps are all UTC for the same reason.

The deliberate choice: log timestamps stay timezone-agnostic across multi-region deployments and survive DST transitions without shifting. Display layers convert to the operator’s local time at render time — the GUI uses each user’s displayDateFormat setting, and the CLI honours it too. If a log line on docker logs <node> reads 16:53 while your wall clock reads 18:53, that is the UTC-vs-local-zone offset, not drift.

To override for local convenience in dev or in a single-region deployment, add -e TZ=Europe/Brussels (or your zone) to the container’s environment, or mount the host clock read-only with -v /etc/localtime:/etc/localtime:ro. Neither is needed for production — application-level time handling does not depend on the container’s local TZ.


GUI Architecture

The Web GUI is a server-rendered PHP application served on the same port as the REST API (8080). It uses an MVC-like structure with controllers, helpers, and HTML templates.

GUI Component Structure

/src/gui/
├── controllers/              # POST handlers (registered with GuiActionRegistry,
│   │                         # see "GUI Action Registry" below)
│   ├── ContactController     # add/accept/block/delete contacts, ping, chain-drop
│   ├── TransactionController # sendEIOU, P2P approve/reject, getP2pCandidates
│   ├── PaymentRequestController # create/approve/decline/cancel payment requests
│   ├── SettingsController    # updateSettings, debug-report endpoints, analyticsConsent
│   ├── DlqController         # dlqRetry/Abandon/RetryAll/AbandonAll
│   ├── PaybackMethodsController # CRUD for payback rails (sentinel-unwind pattern)
│   ├── PluginController      # plugin list / toggle / restart-banner / uninstall
│   └── ApiKeysController     # API key CRUD (TIER_SENSITIVE for mutations)
├── helpers/                  # View data preparation
│   ├── ContactDataBuilder    # Builds contact data arrays for templates
│   ├── MessageHelper         # Flash message formatting and display
│   └── ViewHelper            # Common view utilities
├── functions/
│   ├── Functions             # POST router (dispatcher → GuiActionRegistry); GET handlers
│   ├── coreInlineActions     # No-controller POST closures (whatsNew, rememberSession,
│   │                         # search/loadMore for transactions+payment requests)
│   ├── TemplateHelpers       # cspNonce, formatTimestamp, status-icon maps,
│   │                         # renderSection(), renderTable(),
│   │                         # renderTransactionRowsForAjax() etc
│   └── WalletTemplateHelpers # Post-auth template helpers (renderSection lives here)
├── includes/
│   └── Session               # Secure session management (auth code-based + remember-me rotation tokens)
├── layout/
│   ├── authenticationForm    # Login page (auth code entry)
│   ├── wallet.html           # Main wallet layout (authenticated)
│   └── walletSubParts/       # Wallet page sections (every standard section
│       │                     # rendered through renderSection() — uniform
│       │                     # form-container chrome + section-intro + body)
│       ├── header / banner / notifications / quickActions / floatingButtons
│       ├── walletInformation / paybackMethodsSection / dashboardTab
│       ├── contactSection / contactsTab / pending-contacts (in contactSection)
│       ├── eiouForm / sendTab / paymentRequestsSection
│       ├── transactionHistory / activityTab
│       ├── settingsSection / settingsTab / apiKeysSection / pluginsSection /
│       │   debugSection
│       └── _contactRow / _transactionHistoryRow / _paymentRequestRow (row partials
│           reused by initial-render and AJAX append paths)
└── assets/
    ├── css/                  # Stylesheets
    ├── js/                   # JavaScript (vanilla, Tor-compatible)
    └── fontawesome/          # Icon library

GUI Action Registry

Every POST request from the wallet GUI flows through GuiActionRegistry. Plugin handlers and core handlers register against the same registry — there’s no separate plugin path.

The dispatcher at the top of Functions.php looks up $_POST['action'], checks the registered tier (public / auth / csrf / sensitive), and calls the handler. Tiers control what the registry enforces before dispatch:

  • TIER_PUBLIC — no gate (rare; reserved for unusual cases).
  • TIER_AUTH — authenticated session. Registry routes but does NOT check CSRF, so the handler can keep its existing rotating verifyCSRFToken() call and its existing failure-response shape (e.g. plain-text 403 for HTML form submits).
  • TIER_CSRF — auth + non-rotating validateCSRFToken($t, false). On failure the registry emits {"success":false,"error":"csrf_error","message":"..."} JSON 403. Default for new plugin AJAX handlers.
  • TIER_SENSITIVE — TIER_CSRF + the session must hold a recent sensitive-access grant (auth-code re-prompt, several minutes).

Most core entries register at TIER_AUTH because each handler keeps its own inline rotating-vs-non-rotating CSRF semantics and its own legacy envelope shape (no behavior change vs the pre-migration if-ladder). The updateSettings core entry is the exception: it registers at TIER_SENSITIVE so the registry’s pre-dispatch emits the canonical JSON 403 sensitive_access_required envelope on a lapse, which the AJAX handler in script.js maps to the apiKeysVerifyModal and re-submits on successful unlock.

Last-write-wins on collision. A plugin that registers an action with a core action’s name overrides core. The dispatcher invokes whatever’s last-registered in the registry. This is documented as the override mechanic in docs//docs/reference/plugins — plugins doing this MUST mirror the existing envelope shape or JS clients will break.

Each controller exposes a registerActions(GuiActionRegistry $r) method called from gui/index.html after construction. No-controller AJAX handlers register from gui/functions/coreInlineActions.php (required from the top of Functions.php before the dispatcher).

Functions.php’s POST router has zero hardcoded if/in_array($action, ...) branches as of this writing.

Plugin GUI Hooks

Beyond the action registry, the GUI exposes four extension surfaces that let plugins extend rendering without forking templates:

  • Render hooks (gui.<area>.<position>) — fire-and-collect. Listeners return HTML strings; the host concatenates them at the fire site. Includes gui.head.styles, gui.head.scripts, gui.footer.scripts, gui.dashboard.before/after, gui.contacts.after, gui.activity.after, gui.settings.section, plus gui.section.before.<id> and gui.section.after.<id> fired automatically by renderSection().
  • Filter hooks (gui.<area>) — value-pipeline. Each listener receives the value from the previous stage. Includes gui.tabs, gui.dashboard.widgets, gui.contact_modal.tabs / gui.contact_modal.body, gui.contact.actions.
  • PluginAssetRegistry — plugins call enqueueStyle() / enqueueScript() in boot(); the host inlines small files with CSP-nonce or serves larger files via /gui/plugin-assets/<id>/<path> (validated by PluginAssetServer).
  • TabRegistry — the five core tabs are registry entries. Plugins add their own tabs at chosen order.

renderSection() and renderTable() helpers in WalletTemplateHelpers.php give plugin-authored sections the same chrome as core sections. See docs//docs/reference/plugins “Extending the GUI” for the plugin-author reference.

Optional PLUGIN_HOOKS_TRACE=1 env flag logs every hook fire (kind, hook, listener count, errors) — useful for plugin authors discovering which hooks the host actually calls without grepping templates.

Session Management

Authentication uses the node’s auth code (derived from the wallet seed). The Session class implements secure session handling:

  • Session cookies: httponly, samesite=Strict, secure (when HTTPS)
  • Auth code comparison with timing-safe equality check
  • Session regeneration on login to prevent fixation attacks

Alternate Auth Code

The primary auth code is 20 hex characters derived deterministically from the BIP39 seed (HMAC-SHA256 with context 'eiou-auth-code'). It is strong (~80 bits) but not memorable, so operators typically read it from their seed-phrase backup or password manager. The alternate auth code is an optional user-chosen passphrase that authenticates GUI requests interchangeably with the primary at the login form and the sensitive-action re-auth gate.

Aspect Primary Alt
Source Derived from BIP39 seed User-chosen
Entropy ~80 bits Variable; enforced floor via strength rules
Storage AES-256-GCM in userconfig.json::authcode_encrypted Argon2id one-way hash in userconfig.json::altcode_hash
Recoverable Yes — re-derive from seed No — primary always rotates it
Recovers primary? n/a No — no path

Strength rules (enforced in Eiou\Utils\AltCodeValidator): ≥12 characters; at least one each of uppercase, lowercase, digit, symbol; no triple-repeated character (aaa); no monotonic ascending/descending run of length ≥4 (abcd, 4321); not a substring match against the bundled common-password tripwire list.

Why Argon2id and not encryption (symmetric with the primary)? The master key that would encrypt the alt code is derived from the seed and sits next to userconfig.json on the same volume. Encrypting with that key adds nothing against an attacker who reads the file. A slow memory-hard hash (Argon2id) forces real offline-attack cost even with full filesystem access — which matters because the alt code is user-chosen and therefore typically far lower entropy than the seed-derived primary.

Verification (single chokepoint, two physical callsites). Session::authenticate() takes both the plaintext primary and the optional alt-code Argon2id hash. The primary check is hash_equals (constant-time string compare); the alt check delegates to AltCodeVerifier::verify(), which always runs password_verify — against a per-process placeholder hash (random plaintext, default Argon2id cost) when no real alt-code hash is configured. The placeholder is lazily initialised on first use and cached for the life of the process. This makes the auth path’s wall-clock time identical regardless of whether an alt code is set, so a network observer cannot infer alt-code presence by timing failed logins. ApiKeysController::verify() (the sensitive-access gate that fronts payback methods, plugin management, settings mutations, and api-key CRUD) uses the same verifier. On success, SessionKeys::AUTH_VIA_ALT is set to true for alt-only authentications so downstream code can distinguish the two.

Self-rotation forbidden. AltCodeController::altCodeSet and altCodeClear both refuse alt-authenticated sessions outright (alt_session_forbidden) and require the primary to be re-entered in-band — not via the session sensitive-access grant. This prevents an attacker who learns the alt code from rotating it and locking the legitimate operator out. The settings GUI mirrors this server-side gate with an alert-warning callout and aria-disabled-styled action buttons when the current session is alt-authenticated; clicking a locked button scrolls the callout into view and flashes it instead of silently no-op’ing.

Online resistance — two rate-limit buckets. gui_login (10 attempts / 60 s window, 5-minute block on excess; defaults from Constants.php) caps online brute-force on the login form at ~876k attempts/year worst case. Combined with the 12-character minimum and forced complexity, online cracking is infeasible. Independently, gui_altcode_modify (5 attempts / 5 min, 15-minute block) gates altCodeSet and altCodeClear so an attacker holding a stolen session cookie cannot probe primary candidates at unlimited rate on those endpoints. The two buckets are deliberately separate so attempts on one surface cannot drain the other. The modify-bucket check fires AFTER the alt_session_forbidden gate so an alt-code-only session can never burn the bucket and lock the legitimate operator out of rotating. Disabling rate limiting via rate_limit_enabled=false shifts the alt code’s online resistance entirely onto password strength and Argon2id work factor — viable only with very strong passphrases.

Remember-Me Login (Rotation Tokens)

The GUI login form offers a “Remember this browser for N days” checkbox. When ticked, on successful auth the node mints a random 32-byte token, stores only its SHA-256 hash in the remember_tokens table, and writes the raw token into an EIOU_REMEMBER_<nodeHash> cookie (HttpOnly, SameSite=Strict, Secure when HTTPS). The cookie name is suffixed with substr(sha256(public_key_pem), 0, 16) so two nodes sharing a hostname (typical dev: localhost:443 for one node, localhost:8443 for another — cookies ignore port per RFC 6265) don’t clobber each other’s tokens. The same per-node suffix also applies to the PHP session cookie name. In production each node has its own hostname so the suffix is invisible but harmless; in dev it’s load-bearing for being able to stay logged into two nodes simultaneously.

On every subsequent page load where no session is authenticated but the cookie is present, gui/index.html asks RememberTokenService::rotateToken(raw, ua) to consume the cookie. Rotation is single-use: the matching row is revoked and a fresh token is issued in its place, inheriting the old row’s remaining lifetime — this catches stolen-cookie replay because the thief’s copy is invalidated the moment the real owner next logs in.

Two user-editable caps govern the feature:

Setting Default Options
rememberMeMaxDays 30 1, 7, 14, 30, 60, 90
rememberMeMaxDevices 3 1, 3, 5, 10

When the device cap is exceeded, the row with the oldest last_used_at is revoked (LRU eviction) before the new one is inserted, so the cap is never breached.

The Active Sessions panel in Settings lists remembered browsers by a truncated User-Agent family (Firefox 128 · Linux rather than the raw UA) — no IPs or fingerprinting data are persisted. The user can sign out any remembered browser individually, or all at once.

Logout revokes the current token and clears the cookie. Restoring a wallet from seed revokes every remembered session for that pubkey so the restored wallet starts with a clean device list. CleanupService prunes expired and revoked rows on its regular sweep.

Tor Compatibility

All GUI JavaScript uses Tor-compatible patterns:

  • var instead of let/const (older Tor Browser versions)
  • className instead of classList
  • Vendor-prefixed flex properties
  • No external resource loading (all assets bundled)

CLI Architecture

The CLI is the primary interface for node management, accessible via the eiou command inside the Docker container.

CLI Component Structure

/src/cli/
├── CliOutputManager     # Output format controller (text or JSON mode)
└── CliJsonResponse      # Standardized JSON response formatter (RFC 9457-inspired)

/src/services/
└── CliService           # Command implementations (88KB, largest service)

Output Modes

The CliOutputManager singleton supports two output modes:

Mode Flag Output
Text (default) — Human-readable formatted output with colours
JSON --json Structured JSON with metadata, timing, error codes

JSON Response Format (based on kubectl, docker CLI, gh CLI patterns):

{
  "status": "success",
  "command": "send",
  "data": { ... },
  "metadata": {
    "version": "1.0.0",
    "nodeId": "alice",
    "executionTime": "0.234s"
  }
}

Command Dispatch

The Eiou.php entry point handles command routing:

eiou <command> [args...] [--json] [--help]
    |
    +-- Parse arguments, detect --json flag
    +-- Initialize Application singleton
    +-- Route to CliService method
    +-- Catch ServiceExceptions → formatted output

Payload Schemas

The /src/schemas/payloads/ directory defines structured payload builders for all message types exchanged between nodes. Each payload class extends BasePayload and provides type-safe construction of the eIOU wire protocol messages.

Payload Hierarchy

BasePayload (abstract)
    |
    +-- TransactionPayload    # Standard send transactions
    +-- ContactPayload        # Contact request/acceptance messages
    +-- ContactStatusPayload  # Ping/pong status messages (per-currency chain validation and credit exchange)
    +-- P2pPayload            # P2P routing request messages
    +-- Rp2pPayload           # Return P2P response messages
    +-- MessagePayload        # General inter-node messages (sync, tx drop)
    +-- UtilPayload           # Utility messages (debug, test)

BasePayload

The abstract BasePayload provides common functionality:

  • Access to UserContext for sender identity (public key, addresses)
  • Access to UtilityServiceContainer for currency formatting, time formatting, validation, and transport services
  • Envelope construction with sender signature (secp256k1 ECDSA)

OutputSchema

The OutputSchema class (/src/schemas/OutputSchema.php) defines standardized response formats for API and CLI output, ensuring consistent field naming and structure across all endpoints.


Security Model

Key Management

BIP39 Mnemonic:

  • 24-word seed phrase generated using cryptographically secure random
  • Used to derive all cryptographic material deterministically
  • Stored encrypted with AES-256-GCM

Key Derivation:

Mnemonic (24 words)
        |
        v
BIP39::mnemonicToSeed()
        |
        v
   Seed (512 bits)
        |
        +----------------------------+
        |                            |
        v                            v
BIP39::seedToKeyPair()      HKDF-SHA256 (context:
        |                    "eiou-master-key")
        v                            |
+-------------------+                v
| secp256k1 Keypair |     +---------------------+
| - Private Key     |     | Master Encryption   |
| - Public Key      |     | Key (256 bits)      |
+-------------------+     | - At-rest encryption|
        |                 | - Recoverable via   |
        v                 |   seed restore      |
TorKeyDerivation          +---------------------+
        |
        v
+-------------------+
| Ed25519 Keypair   |
| - .onion Address  |
+-------------------+

Security Components

Component Path Purpose
BIP39 /src/security/BIP39.php Mnemonic generation, seed derivation, secp256k1 keypair creation. getPreferredCurve() hard-requires secp256k1; a node whose linked OpenSSL lacks it refuses to start (see Application::__construct). No prime256v1 fallback — such a node cannot parse any peer’s public key and is effectively isolated
KeyEncryption /src/security/KeyEncryption.php AES-256-GCM encryption/decryption for private keys and auth codes
MariaDbEncryption /src/security/MariaDbEncryption.php Transparent Data Encryption (TDE) — derives TDE key from master key via HMAC-SHA256, writes key file to /dev/shm, enables file_key_management plugin, encrypts all InnoDB/Aria tables at rest
PayloadEncryption /src/security/PayloadEncryption.php ECDH + AES-256-GCM end-to-end encryption for all contact message payloads
TorKeyDerivation /src/security/TorKeyDerivation.php Derives Ed25519 keypairs from secp256k1 keys for Tor v3 hidden service addresses
VolumeEncryption /src/security/VolumeEncryption.php Optional master key protection at rest — encrypts the master key with a passphrase-derived key (Argon2id + AES-256-GCM) so the host server cannot read it from the Docker volume without the passphrase

Encrypted Storage

Item Encryption File
Private Key AES-256-GCM /etc/eiou/config/userconfig.json
Auth Code AES-256-GCM /etc/eiou/config/userconfig.json
Alt Code Argon2id one-way hash (password_hash) /etc/eiou/config/userconfig.json
Mnemonic AES-256-GCM Displayed once, not stored
Database credentials AES-256-GCM with AAD /etc/eiou/config/dbconfig.json
All database files MariaDB TDE (file_key_management) /var/lib/mysql/ (InnoDB, Aria, redo logs, temp tables, binlog)
Master key (optional) Argon2id + AES-256-GCM /etc/eiou/config/.master.key.enc (when volume passphrase active)

Payload Encryption (E2E)

All messages sent to known contacts are end-to-end encrypted using ephemeral ECDH key agreement + AES-256-GCM. Every content field — including type — is encrypted, making all message types (P2P, RP2P, transactions, pings, route cancellations, etc.) indistinguishable on the wire. Encryption happens in TransportUtilityService::signWithCapture() (encrypt-then-sign), decryption in index.html before message routing.

Envelope-level fields (outside signed content):

signWithCapture() lifts certain metadata out of the signed message content and places it at the top level of the transport envelope alongside the signature. These fields are not signed because they are transport metadata that can legitimately change over time:

Field Description
senderAddress The specific address used for this transport hop
senderPublicKey Sender’s public key for signature verification
senderAddresses All of the sender’s known addresses (HTTP, HTTPS, Tor). Included when the payload contains senderAddresses; absent otherwise. Recipients store these to enable fallback transport for future messages.
signature secp256k1 signature over the signed message content
version Sender’s app version (omitted for type=create contact requests)
message JSON-encoded signed content (may be E2E encrypted)

The receiver extracts all envelope-level fields and merges them into the request array before routing, so handlers receive senderAddress, senderPublicKey, and senderAddresses as regular request fields.

Excluded from encryption:

  • create (contact requests) — recipient is not yet a contact, so their public key is unknown and E2E encryption is not possible. All fields in the contact request payload (including the optional description/message) are sent in cleartext within the signed envelope. Transport-level encryption (Tor or HTTPS) provides the only confidentiality layer for the initial contact request. Users should avoid including sensitive information in contact request messages when using plain HTTP transport.

Graceful cleartext fallback (recipient public key unavailable):

  • Transaction inquiry to P2P end-recipient — not necessarily a direct contact
  • Contact acceptance inquiry to pending contacts — public key not yet known
  • Any message where ContactRepository::getPublicKeyFromAddress() returns null

The signed message structure is {encrypted: {ciphertext, iv, tag, ephemeralKey}, nonce} when encrypted, or {...fields..., nonce} when falling back to cleartext. The receiver decrypts the encrypted block (if present) before type-based routing.

Sync compatibility: Because the signature covers the encrypted content (not the plaintext), the raw signed JSON is stored in the signed_message_content column of the transactions table. During chain sync recovery, verifyTransactionSignature() uses this stored content instead of reconstructing from plaintext DB fields, which would produce a different hash. A plaintext (non-encrypted) value transaction stores no signed_message_content; its signature is reconstructed from the wire columns, and the value fields it covers are already in those columns, so the reconstructed bytes match what was signed.

E2E Message Flow:

  SENDER                                              RECIPIENT
    |                                                     |
    |  1. Build message payload                           |
    |     {type, amount, currency, ...}                   |
    |                                                     |
    |  2. Lookup recipient public key                     |
    |     ContactRepo::getPublicKeyFromAddress()          |
    |                                                     |
    |  3. Encrypt ALL fields (if public key found)        |
    |     PayloadEncryption::encryptForRecipient()        |
    |     - Generate ephemeral EC keypair                 |
    |     - ECDH(ephemeral_private, recipient_public)     |
    |     - HKDF-SHA256 → symmetric key                  |
    |     - AES-256-GCM encrypt                           |
    |     → {encrypted: {ciphertext, iv, tag,             |
    |        ephemeralKey}}                                |
    |                                                     |
    |  4. Sign encrypted payload (encrypt-then-sign)      |
    |     Security::signMessage()                         |
    |     → {encrypted: {...}, nonce, signature}           |
    |                                                     |
    |  5. Send via HTTP/HTTPS/Tor                         |
    |---------------------------------------------------->|
    |                                                     |
    |                    6. Verify signature (no decrypt)  |
    |                    7. Decrypt with own private key   |
    |                       PayloadEncryption::            |
    |                         decryptFromSender()          |
    |                    8. Route by decrypted type        |
    |                                                     |

Message Signing and Integrity

All node-to-node messages are signed with the sender’s secp256k1 key through a single helper, MessageSignature (files/src/security/MessageSignature.php), which is the only place openssl_sign / openssl_verify are called.

Hash algorithm. Signatures hash with SHA-256 (SIGN_ALGO = OPENSSL_ALGO_SHA256). Historically the code used OpenSSL’s default, SHA-1, which is collision-broken and unfit for authenticating value. A raw ECDSA signature does not embed its hash algorithm, so verify() tries SHA-256 first and falls back to SHA-1, keeping signatures from not-yet-upgraded peers verifiable. Because a SHA-256 signature cannot be verified by a peer that only knows SHA-1, Constants::MIN_COMPATIBLE_VERSION was raised to the release that introduced this, so a too-old peer is refused at the compatibility check (InputValidator) rather than silently failing every verification across a partially upgraded mesh. A later hard cutover that refuses SHA-1 is a separate, version-gated change.

Version-2 value-field commitment. For an E2E transaction the signature covers only the ciphertext, not the wire amount / currency / txid / previous_txid columns the ledger reads. A version-2 transaction message (marked by a cleartext v field) carries, alongside the encrypted body, an fc field: a length-prefixed SHA-256 commitment over those four value fields, computed identically by signer and verifier (MessageSignature::fieldCommitment(), amounts normalized through SplitAmount). The signature covers fc, and a verifier recomputes it from the stored wire columns and compares with hash_equals. This binds the wire fields to the signature without decrypting, so it holds even for a transaction this node sent (whose body is encrypted to the other party). The v marker also pins SHA-256 on verification (sha256Only), so a version-2 message cannot be downgraded to the SHA-1 fallback. The commitment is appended only for encrypted sends (signWithCapture() gates it on $wasEncrypted); a plaintext value send already carries the fields inside its signed bytes and is reconstructable from the wire columns, so adding v/fc there would make the signer and the reconstruction path disagree and drop the row.

Restore and Re-Sync Integrity

When a node rebuilds its history from a contact after data loss, that contact is the sole source for the pair, so SyncService treats every ingested row as untrusted. Both ingest paths (chunked syncTransactionChain and bidirectionalSync) gate the insert on the same checks:

Check Method Enforces
Sender signature verifyTransactionSignature Valid sender signature over the (stored or reconstructed) signed content.
Recipient signature verifyRecipientSignature An accepted/completed row has the recipient counter-signature. A memo='contact' row may skip it only when its amount is actually zero; a non-zero contact-memo row with no recipient signature is rejected.
Value reconciliation reconcileE2eFields A version-2 row reconciles against its fc commitment (no decryption). A version-1 E2E row the node can decrypt (it is the recipient) must have its wire amount/currency/txid/previous-link match the signed content. A sent-side row that cannot be decrypted and carries no commitment is accepted but flagged needs_manual_review rather than having its wire amount trusted.
Party binding inline parties check The row is between exactly this node and the contact being synced; a validly-signed row between unrelated parties is refused.
Status preservation inline status filter The peer-reported status is stored verbatim (cancelled/rejected keep their state); pending/sending/empty are refused, so nothing is laundered into completed.

Auto-accepting a restored contact (ContactStatusService): a restored contact is auto-accepted with its default credit only when hasCompletedTransactionBetween() finds a completed value transaction for the pair. That query excludes memo='contact' rows, because a zero-amount contact row can carry only the peer’s signature (the zero-amount bypass above), so a completed contact row a peer forged with a fresh key is not proof of a prior dealing. A completed standard or P2P transaction necessarily carries this node’s own signature, which a peer cannot forge. Without that proof the contact stays pending for manual review.

Transport Security

Layer Protection
HTTPS TLS 1.2+ with auto-generated or custom certificates
Tor Onion routing for IP anonymization
Message Signing secp256k1 ECDSA signatures (SHA-256, SHA-1 verify fallback during transition) on all messages; version-2 transactions bind a cleartext value-field commitment. See Message Signing and Integrity
E2E Encryption ECDH + AES-256-GCM for all contact message payloads (type-indistinguishable)

SSL Certificate Priority Chain:

Priority Source Configuration
1 External certificates Mount to /ssl-certs/ volume
2 Let’s Encrypt (certbot) LETSENCRYPT_EMAIL env var; certbot state persisted under the ssl/letsencrypt/ subdirectory of the unified ssl-cert volume (symlinked to /etc/letsencrypt)
3 CA-signed generation Mount CA key/cert to /ssl-ca/ volume
4 Self-signed (fallback) Auto-generated on startup

Let’s Encrypt Integration:

  • In-container certbot for single-node deployments
  • Host-level scripts for multi-node or wildcard certificates:
    • scripts/create-ssl-letsencrypt.sh — obtain certs (HTTP-01 or DNS-01 wildcard)
    • scripts/renew-ssl-letsencrypt.sh — automated renewal for cron
  • Automatic renewal cron inside containers using Let’s Encrypt
  • Wildcard certs shared across multiple nodes via /ssl-certs/ volume mount
  • Environment variables: LETSENCRYPT_EMAIL, LETSENCRYPT_DOMAIN, LETSENCRYPT_STAGING

API Authentication

API requests use HMAC-SHA256 signature-based authentication:

Signature = HMAC-SHA256(string_to_sign, api_secret)

string_to_sign = METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + BODY

Security Features:

Feature Implementation
Replay Prevention Timestamps must be within 5 minutes
Secret Protection Only signature sent, never the secret
Rate Limiting Per-key limits (default: 100 req/min)

Rate Limiting

The RateLimiterService protects against abuse:

RATE_LIMIT_ENABLED = true  // Always true in production

Warning: Only disable rate limiting during development debugging.


Error Handling

Error Handling Architecture

The application uses a layered error handling approach with specialized exceptions for business logic errors and a global safety net for unexpected failures.

                    +------------------------------------------+
                    |            ErrorHandler.php               |
                    |   (Global safety net - set_exception_     |
                    |    handler for truly uncaught errors)     |
                    +------------------------------------------+
                                        ^
                                        | (only if not caught below)
                    +-------------------+-------------------+
                    |                                       |
        +-----------+------------+          +---------------+----------------+
        |      Eiou.php          |          |         ApiController          |
        |    (CLI Entry)         |          |         (API Entry)            |
        +------------------------+          +--------------------------------+
        | catch Validation ->    |          | catch ServiceException ->      |
        |   format + exit(1)     |          |   use getMessage()             |
        |                        |          |   use getHttpStatus()          |
        | catch Fatal ->         |          |   use getErrorCode()           |
        |   format + exit(1)     |          |                                |
        |                        |          | catch Exception ->             |
        | catch Recoverable ->   |          |   generic 500 error            |
        |   format + exit(0)     |          |                                |
        +-----------+------------+          +---------------+----------------+
                    |                                       |
                    +-------------------+-------------------+
                                        |
        +-------------------------------+-------------------------------+
        |                        Service Layer                          |
        |   ContactService, MessageService, WalletService, etc.         |
        |                                                               |
        |   throw ValidationServiceException("Invalid name", ...)       |
        |   throw FatalServiceException("Wallet not found", ...)        |
        +---------------------------------------------------------------+

ServiceException Hierarchy

The ServiceException classes (/src/exceptions/) provide structured error handling for business logic errors, replacing direct exit() calls in service methods.

ServiceException (abstract)
    |
    +-- FatalServiceException
    |     Unrecoverable errors (missing wallet, unauthorized access)
    |     Exit code: 1
    |
    +-- RecoverableServiceException
    |     Retryable errors (network timeouts, temporary unavailability)
    |     Exit code: 0 (configurable)
    |
    +-- ValidationServiceException
          Input validation errors (invalid address, invalid name)
          Exit code: 1
          Includes field name for targeted error display

ServiceException Properties:

Property Type Description
errorCode string Maps to ErrorCodes constants
httpStatus int HTTP status code for API responses
context array Additional debugging data

Key Methods:

$exception->getMessage();      // Human-readable error message
$exception->getErrorCode();    // ErrorCodes constant (e.g., INVALID_NAME)
$exception->getHttpStatus();   // HTTP status (e.g., 400, 404, 500)
$exception->getContext();      // Additional context array
$exception->getExitCode();     // CLI exit code (0 or 1)
$exception->toArray();         // Full error as array for JSON
$exception->toJson();          // JSON-encoded error response

Error Handling by Entry Point

CLI Entry Point (Eiou.php):

try {
    // Command dispatch...
} catch (ValidationServiceException $e) {
    $output->error($e->getMessage(), $e->getErrorCode(), $e->getHttpStatus());
    $logger->warning("Validation error", ['field' => $e->getField()]);
    exit($e->getExitCode());  // exit(1)

} catch (FatalServiceException $e) {
    $output->error($e->getMessage(), $e->getErrorCode(), $e->getHttpStatus());
    $logger->error("Fatal service error", ['context' => $e->getContext()]);
    exit($e->getExitCode());  // exit(1)

} catch (RecoverableServiceException $e) {
    $output->error($e->getMessage(), $e->getErrorCode(), $e->getHttpStatus());
    $logger->info("Recoverable error");
    exit($e->getExitCode());  // exit(0)
}

API Entry Point (ApiController):

try {
    $response = match ($resource) { ... };
} catch (ServiceException $e) {
    // Use rich error context from exception
    $response = $this->errorResponse(
        $e->getMessage(),
        $e->getHttpStatus(),
        strtolower($e->getErrorCode())
    );
} catch (Exception $e) {
    // Generic fallback for unexpected errors
    $response = $this->errorResponse('Internal server error', 500, 'internal_error');
}

ErrorHandler (Global Safety Net)

The ErrorHandler class (/src/core/ErrorHandler.php) provides last-resort handling for any exceptions that escape the entry point try-catch blocks.

Initialization:

ErrorHandler::init();  // Called during Application bootstrap

What It Handles:

Handler Purpose
set_error_handler() PHP errors (warnings, notices)
set_exception_handler() Uncaught exceptions
register_shutdown_function() Fatal errors on shutdown

Environment-Aware Output:

Environment Behavior
Production Shows generic “An error occurred” message
Development Shows full error details, stack trace

When to Use Each Exception Type

Scenario Exception Type Example
Invalid user input ValidationServiceException Bad address format, invalid name
Missing required resource FatalServiceException Wallet doesn’t exist
Unauthorized action FatalServiceException Invalid message source
Network timeout RecoverableServiceException Contact temporarily unreachable
Rate limited RecoverableServiceException Too many requests

Throwing Exceptions in Services

// Validation error with field context
throw new ValidationServiceException(
    "Invalid name: " . $validation['error'],
    ErrorCodes::INVALID_NAME,
    'name',           // Field that failed
    400               // HTTP status
);

// Fatal error with context
throw new FatalServiceException(
    "Wallet does not exist. Run 'generate' or 'restore' first.",
    ErrorCodes::WALLET_NOT_FOUND,
    ['requested_action' => $request],  // Context for debugging
    404
);

// Recoverable error
throw new RecoverableServiceException(
    "Contact temporarily unavailable",
    ErrorCodes::CONTACT_OFFLINE,
    ['retry_after' => 60],
    503,
    0  // Exit code 0 (not a hard failure)
);

Integration with ErrorCodes

ServiceExceptions integrate with the existing ErrorCodes class for consistent error identification:

// ErrorCodes provides:
ErrorCodes::INVALID_NAME        // Error code constant
ErrorCodes::getHttpStatus($code)  // Auto-detect HTTP status from code
ErrorCodes::getTitle($code)     // Human-readable title

Testing Error Paths

ServiceExceptions enable proper unit testing of error conditions:

// Test that validation errors are properly thrown
public function testSearchContactsWithInvalidName(): void
{
    $this->expectException(ValidationServiceException::class);
    $this->expectExceptionMessage('Invalid name');

    $contactService->searchContacts(['eiou', 'search', '<script>'], $output);
}

API and CLI Reference

Document Description
/docs/reference/api-reference Complete REST API documentation
/docs/reference/api-quick-reference API endpoint quick reference
/docs/reference/cli-reference Command-line interface guide

GUI Documentation

Document Description
/docs/reference/gui-reference Web interface documentation
/docs/reference/gui-quick-reference GUI quick reference card
/docs/reference/plugins Plugin authoring guide — see “Extending the GUI” for plugin GUI hook usage (render slots, filter slots, asset registry, tab/action registries)

Configuration and Errors

Document Description
/docs/reference/docker-configuration Container configuration options
/docs/reference/error-codes Error code reference

Source Code Locations

Component Path
Application /app/eiou/src/core/Application.php
ServiceContainer /app/eiou/src/services/ServiceContainer.php
DI Container Config /app/eiou/src/config/container.php
ErrorHandler /app/eiou/src/core/ErrorHandler.php
Exceptions /app/eiou/src/exceptions/
Processors /app/eiou/src/processors/
Repositories /app/eiou/src/database/
Repository Traits /app/eiou/src/database/traits/
Services /app/eiou/src/services/
Service Proxies /app/eiou/src/services/proxies/
Formatters /app/eiou/src/formatters/
Utility Services /app/eiou/src/services/utilities/
Utils (Logging, Validation) /app/eiou/src/utils/
Security (BIP39, Encryption) /app/eiou/src/security/
Contracts (Interfaces) /app/eiou/src/contracts/
Events /app/eiou/src/events/
Payload Schemas /app/eiou/src/schemas/payloads/
CLI /app/eiou/src/cli/
GUI Controllers /app/eiou/src/gui/controllers/
GUI Templates /app/eiou/src/gui/layout/
GUI Helpers /app/eiou/src/gui/helpers/
Startup Checks /app/eiou/src/startup/
API Controller /app/eiou/src/api/ApiController.php

Document generated from source code analysis. For the latest information, refer to the source files directly.