Reference
Architecture
eIOU Docker Architecture
Technical architecture documentation for the eIOU Docker node implementation.
Table of Contents
- Overview
- System Architecture Diagram
- Core Components
- Service Layer
- Dependency Injection Patterns
- Circular Dependency Management
- Message Processing Pipeline
- Data Layer
- P2P Networking
- Transaction Lifecycle
- Startup Sequence
- Docker Topologies
- GUI Architecture
- CLI Architecture
- Payload Schemas
- Security Model
- Error Handling
- 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
eioucommand (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:
- Parses all PHP service files in
/files/src/services/ - Extracts constructor dependencies (type-hinted parameters)
- Extracts setter injection dependencies (
set*Servicemethods) - Builds a dependency graph
- Uses DFS to detect cycles
- 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:
- Claim: Atomically transitions P2P from
queued→sendingviaP2pRepository::claimQueuedP2p(), recordingsending_started_atandsending_worker_pidfor crash recovery - Broadcast: Calls
P2pService::processSingleP2p()which broadcasts to all accepted contacts via its owncurl_multisession - 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:
- Phase 1 — Collect: Builds per-contact payloads and accumulates them into a
flat
$megaBatchSendsarray. A coalesce delay (P2P_QUEUE_COALESCE_MS, 2000ms) groups concurrent P2Ps arriving within a short window. - Phase 2 — Fire: Calls
TransportUtilityService::sendMultiBatch($megaBatchSends)which executes all sends in parallel viacurl_multiwith a sliding-window concurrency limit (see Transport Concurrency Control). - Phase 3 — Map Results: Maps each
curl_multiresult 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 (
prevTxidsByCurrencymaps) - 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_ENABLEDenvironment 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:
- 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. - Unknown sender detected —
ContactStatusService::handlePingRequest()finds the sender pubkey is not in the contacts table and creates a pending contact record namedRestoredContact<N>(sequentially numbered). - Sync triggered — the node pulls the transaction chain from the remote node via
SyncTriggerInterface::syncTransactionChain(). - Prior relationship verified — if the sync restores transactions between the two pubkeys, this proves a prior relationship existed.
- Accept or hold for review — controlled by
autoAcceptRestoredContactsetting (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.
- Enabled (default): the contact is auto-accepted with the node’s default fee
(
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:
- Tor process death — checked directly each cycle; restarts immediately.
- Peer-send SOCKS5 failure —
TransportUtilityService::send()writes/tmp/tor-restart-requestedwhen 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. - 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): WhenTorCircuitHealthmarks 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 SplitAmountSplitAmount::fromDbRow($row, $prefix)— extracts{prefix}_whole/{prefix}_fracfrom 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
maxRequestLevelunchanged HOP_BUDGET_MIN_RATIO(default: 0.5) prevents uselessly low budgets (1 hop = direct contacts only)- Controlled by the
hopBudgetRandomizeduser setting (GUI/CLI/API toggle, default: enabled). When disabled, returnsmaxP2pLevelfor full routing depth — recommended for sparse trust graphs where reachability matters more than traffic analysis resistance. Also overridable viaEIOU_HOP_BUDGET_RANDOMIZEDenv variable (env takes precedence) - Dead-end behavior: when
requestLevel >= maxRequestLevel, the relay stores as cancelled and sendssendCancelNotificationForHash()upstream immediately
P2P Message Flow
Outbound (P2pService):
- User initiates transaction to unknown recipient
- P2pService creates P2P record with randomized level and status
queued - P2pMessageProcessor daemon picks up queued messages (polls every 100ms–5s)
- Coalesce delay groups concurrent P2Ps into a single mega-batch (2000ms window)
- Mega-batch broadcasts to accepted contacts that support the transaction’s currency via
sendMultiBatch()(curl_multi) - Concurrency-limited sliding window caps simultaneous connections per protocol
- Each relay node queues, coalesces, and broadcasts to its own contacts (level++)
- 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):
- Recipient receives P2P request
- Recipient sends RP2P response back along route
- Intermediaries forward RP2P (reverse path)
- Original sender receives acceptance
- 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--bestflag, 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 viaEIOU_TOR_FORCE_FAST=falseenv variable for testing. - Rejection counting: When
handleRp2pRequest()returns false (fee too high or relay can’t afford),checkRp2pPossible()incrementscontacts_responded_countfor the sender. When all contacts have responded (all rejected or cancelled), the node cancels the P2P immediately and propagates cancel upstream viasendCancelNotificationForHash(), 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:
- All contacts have responded (
contacts_responded_count >= contacts_sent_count) - 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:
-
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_relayedcontacts viasendBestCandidateToRelayedContacts(). Thephase1_sentflag 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. -
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 viahandleRp2pRequest().
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:
- When A4 first receives the P2P from A1, it stores the P2P record
(
p2p.sender_address = A1) and records A1 inp2p_senders. - When A4 receives the duplicate P2P from A3, it returns
already_relayedbut also records A3 inp2p_senders. - 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_secretnever traverses the relay chain — only sent on the direct A→C pathsalt(the commitmentsha256(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. ThematchYourselfP2P/matchContactdocblocks inP2pService.phplink 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
Bis on a path toX— not thatBknowsXdirectly.A-B-XandA-B-C-Xare 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-XandA-B-C-D-Xare 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 placeXin 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).
-
A relay can enumerate candidate addresses it already knows. Every relay receives
salt,time, and the unkeyedhash, so a relay can recomputesha256(candidate · salt · time)for any address it holds — exactly whatmatchContact()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. -
Cross-transport locator linkage.
matchYourselfP2P()checks every locator fromgetUserLocaters()(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 — andisNotBlocked()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 (noEIOU_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
cancelledrow for audit and accounting. The row has a validprevious_txidpointing into the chain but nothing ever points at it — it’s a leaf, not a link. The sender’s chain walker (TransactionChainRepository::getTransactionChain) filtersstatus NOT IN ('cancelled', 'rejected'), so the chain walk skips right past it.verifyChainIntegrityonly inspectscompleted/accepted/paid, so the cancelled row doesn’t trip the verifier either.valid_chainstaystrueon 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_txidpointing at the last completed predecessor — a direct link across the gap the cancelled tx left behind on the sender’s side.valid_chainstaystrueon 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:
- Local self-repair — checks local database backups for missing transactions
- Remote backup request — sends remaining missing txids to the contact via the
missingTxidsfield in the sync request; the contact checks its DB and backups - Tx drop fallback — only if neither side has the missing transactions in any backup,
the
ChainDropServicecoordinates 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,
processOutgoingP2pchecks the remaining P2P lifetime. If less thanHELD_TX_SYNC_TIMEOUT_SECONDSremains, the hold is skipped because the P2P will expire on every other relay node before sync completes. - Resume checks actual expiration timestamp:
isP2pExpiredOrCancelledchecks 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 thanP2P_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:
- Ping/pong: The processor calls
saveAvailableCreditFromPong()which upserts each currency’s credit viaContactCreditRepository::upsertAvailableCredit(). - Contact acceptance: The acceptance message and acknowledgment both include
availableCreditByCurrency, saved viasaveAvailableCreditFromAcceptance()andsaveAvailableCreditFromResponse()respectively. - 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:
- 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. - 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 rotatingverifyCSRFToken()call and its existing failure-response shape (e.g. plain-text 403 for HTML form submits).TIER_CSRF— auth + non-rotatingvalidateCSRFToken($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. Includesgui.head.styles,gui.head.scripts,gui.footer.scripts,gui.dashboard.before/after,gui.contacts.after,gui.activity.after,gui.settings.section, plusgui.section.before.<id>andgui.section.after.<id>fired automatically byrenderSection(). - Filter hooks (
gui.<area>) — value-pipeline. Each listener receives the value from the previous stage. Includesgui.tabs,gui.dashboard.widgets,gui.contact_modal.tabs/gui.contact_modal.body,gui.contact.actions. PluginAssetRegistry— plugins callenqueueStyle()/enqueueScript()inboot(); the host inlines small files with CSP-nonce or serves larger files via/gui/plugin-assets/<id>/<path>(validated byPluginAssetServer).TabRegistry— the five core tabs are registry entries. Plugins add their own tabs at chosenorder.
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:
varinstead oflet/const(older Tor Browser versions)classNameinstead ofclassList- 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
UserContextfor sender identity (public key, addresses) - Access to
UtilityServiceContainerfor 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);
}
Related Documentation
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.