Reference

GUI Reference

eIOU GUI Reference

Complete documentation for the eIOU Docker node web-based GUI.

Table of Contents

  1. Architecture Overview
  2. Directory Structure
  3. Controllers
  4. Layout Components
  5. Session Management
  6. Security Features
  7. Helpers
  8. Known Limitations
  9. Development Setup
  10. See Also

Architecture Overview

The eIOU GUI is a server-rendered PHP web application optimized for Tor Browser compatibility. It follows an MVC-inspired pattern with controllers handling POST requests and HTML templates rendering the view.

Request Flow

Browser Request
       |
       v
   index.html (Entry Point)
       |
       +-- Authentication check (Session.php)
       |       |
       |       +-- Not authenticated --> authenticationForm.html
       |       |
       |       +-- Authenticated --> Continue
       |
       +-- Functions.php (Central Router)
       |       |
       |       +-- POST Request? --> Route to Controller
       |       |       |
       |       |       +-- ContactController
       |       |       +-- TransactionController
       |       |       +-- SettingsController
       |       |
       |       +-- Initialize View Data
       |               |
       |               +-- Balances, Transactions
       |               +-- Contacts (all states)
       |               +-- Address Types
       |               +-- Notification Tracking
       |
       +-- wallet.html (Main Layout)
               |
               +-- Include walletSubParts/*.html
               |
               v
          Rendered HTML Response

Key Characteristics

Characteristic Description
Rendering Server-side PHP with embedded HTML
JavaScript Minimal client-side JS for UX enhancements
Compatibility Optimized for Tor Browser (no WebSockets, limited JS)
State Management Session-based with CSRF protection
Styling Inline CSS via page.css include
Forms Traditional POST form submissions with redirect
AJAX Limited use for specific features (ping, tx drop, debug report)
Amount precision Every currency amount label is clickable: it opens a detail popup with the full 8-decimal (1e-8) value, independent of the displayDecimals setting. Dashboard totals carry exact whole/frac; modal-rendered amounts (contact balance/credit, transaction amounts) carry the major-units float, formatted to 8 decimals on open

Directory Structure

files/src/gui/
├── controllers/                    # Request handlers
│   ├── ContactController.php       # Contact CRUD operations
│   ├── TransactionController.php   # Transaction processing
│   ├── SettingsController.php      # Settings and debug operations
│   ├── PaybackMethodsController.php # Payback-methods CRUD + reveal gate
│   ├── DlqController.php           # Dead letter queue retry/abandon (AJAX)
│   └── PluginController.php        # Plugin list/toggle/public-routes/upload/upgrade (AJAX)
│
├── functions/
│   └── Functions.php               # Central router and view data initializer
│
├── helpers/
│   ├── MessageHelper.php           # Message formatting and parsing
│   ├── ViewHelper.php              # HTML rendering utilities
│   └── ContactDataBuilder.php      # Contact data structure builder
│
├── includes/
│   └── Session.php                 # Session management and security
│
├── layout/
│   ├── authenticationForm.html     # Login page
│   ├── wallet.html                 # Main wallet layout (includes subparts)
│   └── walletSubParts/             # Modular UI components
│       ├── banner.html              # Dynamic image banner carousel
│       ├── header.html              # Page header with Download icon + logout
│       ├── downloadModal.html       # Global export modal (entity + filters + format + view)
│       ├── notifications.html       # Toast and banner notifications
│       ├── quickActions.html        # Quick action cards
│       ├── walletInformation.html   # Balance and address display
│       ├── eiouForm.html            # Send transaction form
│       ├── contactForm.html         # Add contact form
│       ├── contactSection.html      # Contact list and modal
│       ├── transactionHistory.html  # Transaction list and modal
│       ├── dlqSection.html          # Dead letter queue management
│       ├── pluginsSection.html       # Plugins tab (list, enable/disable, public-routes toggle, upload/upgrade)
│       ├── pluginsTab.html           # Plugins tab nav entry
│       ├── paybackMethodsSection.html # Payback methods dashboard section (list + "How payback methods work" intro)
│       ├── paybackMethodForm.html   # Two-step Add / Edit / View modal (type picker → per-type fields)
│       ├── settingsSection.html     # Settings form and debug panel
│       ├── floatingButtons.html     # Back-to-top and refresh buttons
│       └── analyticsConsentModal.html # One-time analytics opt-in modal
│   (wallet.html also contains inline modal definitions for Transaction Details and What's New)
│
└── assets/
    ├── css/
    │   ├── page.css                # Main wallet styles
    │   └── authentication-form.css # Login page styles
    ├── js/
    │   └── script.js               # Client-side JavaScript
    └── fontawesome/                # Icon library

Controllers

Controllers handle POST requests routed through Functions.php. All controllers follow a consistent pattern:

  1. Verify CSRF token
  2. Validate and sanitize input
  3. Execute operation via service layer
  4. Redirect with message

ContactController

Handles all contact-related operations.

Method Action Value Description Parameters
handleAddContact() addContact Add new contact address, name, fee, credit, currency, description (optional)
handleAcceptContact() acceptContact Accept pending request contact_address, contact_name, contact_fee, contact_credit, contact_currency
handleDeleteContact() deleteContact Delete contact contact_address
handleBlockContact() blockContact Block contact contact_address
handleUnblockContact() unblockContact Unblock contact contact_address
handleEditContact() editContact Update contact settings contact_address, contact_name, contact_fee, contact_credit, contact_currency
handlePingContact() pingContact Check contact status (AJAX) contact_address
handleProposeChainDrop() proposeChainDrop Propose dropping missing tx (AJAX) contact_pubkey_hash
handleAcceptChainDrop() acceptChainDrop Accept tx drop proposal (AJAX) proposal_id
handleRejectChainDrop() rejectChainDrop Reject tx drop proposal (AJAX) proposal_id
handleAcceptCurrency() acceptCurrency Accept pending incoming currency pubkey_hash, currency, fee, credit
handleAddCurrency() addCurrency Add a new currency to an existing contact (AJAX) pubkey, currency, fee, credit
handleAcceptAllCurrencies() acceptAllCurrencies Accept all pending currencies for a contact (AJAX) pubkey_hash, currencies (JSON array), is_new_contact, contact_address, contact_name
handleApplyContactDecisions() applyContactDecisions Batched per-currency decisions for a pending contact request — accept some, decline others, leave the rest deferred. Used by the Contact Request modal in place of the per-row Accept/Decline + Accept-All buttons. Thin POST adapter — partition / declines-first / first-accept-via-add logic lives in the shared ContactDecisionService::apply() so the GUI, the eiou contact apply / accept CLI, and POST /api/v1/contacts/:hash/decisions all share one implementation pubkey_hash, decisions (JSON array of {currency, action: "accept"|"decline", fee?, credit?} — defer rows omitted by client), is_new_contact, contact_address, contact_name
handleDeclineContact() declineContact Decline every pending currency on an incoming contact request in one shot. Mirrors eiou contact decline <pubkey-hash> and POST /api/v1/contacts/:hash/decline so all three surfaces share the same effect — useful for “reject this whole request” without enumerating its currencies pubkey_hash
handleDeclineCurrency() declineCurrency Decline a single pending currency on a contact (or contact request), leaving the others untouched, rather than declining the whole request pubkey_hash, currency

AJAX Response Format (pingContact):

{
    "success": true,
    "contact_name": "Bob",
    "online_status": "online",
    "chain_valid": true,
    "message": "Ping complete"
}

Internally, the ping/pong protocol exchanges per-currency data: prevTxidsByCurrency (chain heads per currency), chainStatusByCurrency (per-currency chain validity), availableCreditByCurrency (per-currency available credit), and creditLimitByCurrency (the raw credit the contact extends to us). The AJAX response aggregates chain validity into a single chain_valid boolean. Per-currency available credit is stored in the contact_credit table and displayed in the contact modal.

If you required a minimum credit limit when adding a contact, each pong’s creditLimitByCurrency is checked against that minimum. When a contact grants you less than you required, the contact row shows a warning icon beside its Accepted status and the contact modal’s Status tab shows a “Credit Requirement” notice; clicking the row-header icon jumps to that notice. The flag clears on its own once they grant at least the minimum. This only surfaces the mismatch, since a contact’s extended credit is their own setting and cannot be forced.

When the remote node responds with status: rejected, ContactStatusService::describePingRejection() translates the reason code into a sentence the user can act on rather than echoing the raw token. unknown_contact (the remote responded but doesn’t recognize this user — almost always because the local-side acceptance hasn’t been delivered yet) reads “Contact is online but doesn’t recognize you yet — your acceptance hasn’t been delivered to them. Check the Failed Messages panel under Activity; delivery will keep retrying in the background.” blocked and disabled get reason-specific phrasings; any unrecognized reason falls back to the existing brief form so the message stays accurate as new reasons are added upstream.

The Apply button on the Contact Request modal goes through the page-wide loader pattern (showLoader + startOperationTimeout, mirroring payment-request approve) so a degraded transport — slow Tor circuit or self-signed TLS hop — can’t lock up the UI while the user-initiated outbound notification is in flight. Local state flips immediately; the notification is sent fire-and-forget by ContactSyncService (one synchronous attempt, then DLQ retries on failure) and the GUI auto-reloads after 15s with a post-reload toast pointing at the Failed Messages panel.


TransactionController

Handles transaction operations.

Method Action Value Description Parameters
handleSendEIOU() sendEIOU Send eIOU transaction recipient or manual_recipient, address_type, amount, currency, description
handleCheckUpdates() GET check_updates=1 Poll for updates last_check (timestamp)
handleApproveP2p() approveP2pTransaction Approve a pending P2P transaction (AJAX) hash, optional candidate_id for best-fee mode
handleRejectP2p() rejectP2pTransaction Reject a pending P2P transaction (AJAX) hash
handleGetP2pCandidates() getP2pCandidates Fetch P2P route candidates for best-fee approval (AJAX) hash
handleGetTransactionByTxid() getTransactionByTxid Fetch a single transaction’s detail (AJAX) txid
handleRefund() refundTransaction Return a received transaction to its original sender, in full or in part (“Return to sender”, AJAX) txid, optional amount (omit for the full remaining)
handleGetRefundsForTxid() getRefundsForTxid List the refunds already issued against a received transaction (AJAX) txid

Recipient Resolution:

  1. If manual_recipient is provided, use as-is (P2P routing)
  2. If recipient + address_type, look up specific address
  3. Fallback to recipient name (backend resolution)

SettingsController

Handles settings, encrypted backup management, and debug operations.

Method Action Value Description Parameters
handleUpdateSettings() updateSettings Save wallet settings Multiple settings fields
handleListGuiBackups() listGuiBackups List scheduled and archive-triggered full snapshots (CSRF-gated JSON) None
handleCreateGuiBackup() createGuiBackup Create an encrypted full-database backup (sensitive-access JSON) None
handleVerifyGuiBackup() verifyGuiBackup Verify that a currently listed backup decrypts and contains valid SQL (sensitive-access JSON) filename
handleResetToDefaults() resetToDefaults Wipe every saved setting back to build defaults via UserContext::resetToDefaults() — empties defaultconfig.json so every setting getter falls back to its Constants::-backed default; clears the name key from userconfig.json. Identity fields, contacts, transactions, backups, and API keys are untouched. CSRF-gated. GUI confirmation modal requires typing reset before the submit button enables csrf_token
handleClearDebugLogs() clearDebugLogs Clear debug entries None
handleSendDebugReport() sendDebugReport Generate debug file description
handleGetDebugReportJson() getDebugReportJson Download debug JSON (AJAX) description, report_mode
handleSubmitDebugReport() submitDebugReport Submit debug report to support via Tor (AJAX, non-blocking) description, report_mode
handleAnalyticsConsent() analyticsConsent Save one-time analytics consent choice (AJAX) consent (0 or 1)

What’s New actions (handled directly in Functions.php, not via a controller):

Action Value Description Parameters
whatsNewDismiss Mark current version’s “What’s New” as seen (AJAX) None
whatsNewNotes Fetch release notes from GitHub for a version (AJAX, cached) version

Available Settings:

Setting Type Description
name string Display name shared via QR codes (saved to userconfig.json)
sessionTimeoutMinutes int Session inactivity timeout (5, 10, 15, 30, or 60 minutes)
defaultCurrency string Default currency code
defaultFee float Default fee percentage
minFee float Minimum fee amount
maxFee float Maximum fee percentage
defaultCreditLimit float Default credit limit
maxP2pLevel int Maximum P2P routing hops
p2pExpiration int P2P routing request timeout (seconds); P2P transactions get an extra 120s delivery window after this expires
directTxExpiration int Direct (non-P2P) transaction delivery timeout in seconds; 0 = no expiry (default); recommended: 120s (two Tor round-trips)
maxOutput int Max display lines
defaultTransportMode string Preferred transport (http/https/tor)
autoRefreshEnabled bool Auto-refresh when transactions pending
contactAvatarStyle string Contact avatar rendering style (gradient, pixel, tile)
amountColorScheme string Color scheme for transaction amounts (neutral, western, eastern)
statusColorScheme string Color scheme for status badges (neutral, western, eastern)
autoBackupEnabled bool Enable automatic daily database backups
updateCheckEnabled bool Check Docker Hub daily for newer versions (read-only API call)
autoAcceptTransaction bool Auto-accept P2P transactions when route found (when OFF, transactions pause at awaiting_approval for user review in both fast and best-fee modes)
syncChunkSize int Transactions per sync chunk (10-500)
syncMaxChunks int Max sync chunks per cycle (10-1000)
heldTxSyncTimeoutSeconds int Held tx sync timeout in seconds (30-299)

Advanced Settings are organized into categories via a dropdown selector:

Category Settings
Feature Toggles → Contacts contactStatusEnabled, contactStatusSyncOnPing, autoAcceptRestoredContact, autoRejectUnknownCurrency
Feature Toggles → Transactions autoAcceptTransaction, hopBudgetRandomized, autoChainDropPropose, autoChainDropAccept, autoChainDropAcceptGuard
Feature Toggles → GUI autoRefreshEnabled, hideEmptyGuiSections
Feature Toggles → System apiEnabled, autoBackupEnabled, updateCheckEnabled, analyticsEnabled
Backup & Logging → Backup backupCronTime, backupRetentionCount
Backup & Logging → Logging logMaxEntries, logLevel
Data Retention → Cleanup cleanupDeliveryRetentionDays, cleanupDlqRetentionDays, cleanupHeldTxRetentionDays, cleanupRp2pRetentionDays, cleanupMetricsRetentionDays
Data Retention → Archive paymentRequestsArchiveRetentionDays, paymentRequestsArchiveBatchSize, transactionsArchiveRetentionDays, transactionsArchiveBatchSize
Rate Limiting → Throughput p2pRateLimitPerMinute
Rate Limiting → Attempt Blocking rateLimitMaxAttempts, rateLimitWindowSeconds, rateLimitBlockSeconds
Sync syncChunkSize, syncMaxChunks, heldTxSyncTimeoutSeconds
Network → Transport Timeouts httpTransportTimeoutSeconds, torTransportTimeoutSeconds
Network → Tor Resilience torCircuitMaxFailures, torCircuitCooldownSeconds, torFailureTransportFallback, torFallbackRequireEncrypted
Network → Routing & Delivery maxP2pLevel, p2pExpiration, directTxExpiration
Network → API apiCorsAllowedOrigins
Currency allowedCurrencies
Display displayDecimals, displayDateFormat, displayRecentTransactionsLimit, maxOutput, sessionTimeoutMinutes, contactAvatarStyle, amountColorScheme, statusColorScheme

DlqController

Handles dead letter queue management actions (AJAX only — all responses are JSON).

Method Action Value Description Parameters
handleRetry() dlqRetry Re-send a failed message to its original recipient dlq_id, csrf_token
handleAbandon() dlqAbandon Mark a DLQ item as abandoned dlq_id, csrf_token
handleRetryAll() dlqRetryAll Retry all retryable DLQ items in bulk (transaction + contact types only) csrf_token
handleAbandonAll() dlqAbandonAll Abandon all pending/retrying DLQ items csrf_token

Retry constraints:

  • Only transaction and contact message types can be retried
  • p2p and rp2p items are rejected with an explanatory error — they are time-sensitive relay messages that expire in ≤300s and are stale by the time they reach the DLQ

Retry mechanism: The controller re-sends the original signed payload (stored verbatim in the DLQ) directly to the recipient address using TransportUtilityService::send(). If the recipient returns a success status, the item is marked resolved. On failure it returns to pending.


ApiKeysController

Handles all API-key management done from the wallet GUI’s Settings tab (AJAX only — all responses are JSON). CSRF is validated on every call via Session::validateCSRFToken(..., false) (non-rotating, since the page fires many AJAX calls from a single load). Every destructive call is additionally gated by a short-lived “sensitive access” grant (see below).

Method Action Value Description Parameters
status() (inline) apiKeysStatus Report whether the session currently holds a sensitive-access grant and its seconds remaining. Safe to call from any page render. csrf_token
verify() apiKeysVerify Verify the user’s auth code and open a Session::SENSITIVE_ACCESS_TTL_SECONDS grant (default 5 min) for this session. authcode, csrf_token
clearAccess() (inline) apiKeysClearAccess Drop the sensitive-access grant immediately (user clicked “lock again”). csrf_token
listKeys() apiKeysList List every key on this node — name, key_id, permissions, rate_limit_per_minute, enabled, timestamps, expires_at. No sensitive-access required — key_id values are public identifiers and listing them is non-destructive. csrf_token
createKey() apiKeysCreate Mint a new key. Returns the key_id + secret once; the secret is never retrievable again. Reuses ApiKeyService::validatePermissions() and validateRateLimit() so CLI and GUI agree on what’s accepted. name, permissions[], rate_limit_per_minute (optional, default 100), expires_in_days (optional, 0 or absent = never), csrf_token
toggleKey() apiKeysToggle Enable or disable a single key. Idempotent — toggling a key that’s already in the target state returns success. key_id, enable ("1" or "0"), csrf_token
updateKey() apiKeysUpdate Edit a key’s label, rate limit, and/or expiry. Permissions are intentionally not editable (revoke + reissue to change scope). Expiry may only be shortened — a proposed timestamp later than the current expires_at returns expiration_extension_not_allowed. Each of name / rate_limit_per_minute / expires_in_days is independently optional; omitted fields stay untouched. key_id, any of name, rate_limit_per_minute, expires_in_days, csrf_token
deleteKey() apiKeysDelete Permanently delete a single key. GUI requires the user to type the key’s label to confirm. key_id, csrf_token
(inline) apiKeysDisableAll Disable every currently-enabled key in one statement. Returns the affected count. csrf_token
(inline) apiKeysDeleteAll Permanently delete every key on the node. GUI requires the user to type delete all verbatim before the destructive button enables. csrf_token

Sensitive-access gate: Every mutating action (apiKeysCreate, apiKeysToggle, apiKeysUpdate, apiKeysDelete, apiKeysDisableAll, apiKeysDeleteAll) calls $this->requireSensitive() before handling the request. Without an active grant the controller returns 401 sensitive_access_required with a descriptive message; the client responds by opening the verify modal, collecting the auth code, and retrying the original request automatically on a successful verify. The grant is bound to the session’s auth_time, so logout or a session-timeout rotation invalidates it immediately. Listing (apiKeysList, apiKeysStatus) is deliberately outside the gate because it’s read-only and leaks nothing the operator doesn’t already see.

Bulk-action safety:

  • “Disable all” is only offered when ≥1 enabled key exists; the confirmation modal states the exact active count.
  • “Delete all” is only offered when ≥1 key exists (enabled or disabled); the user must type delete all into a confirmation input before the destructive button enables. No bulk Enable — re-activation is intentionally one key at a time so a recently disabled key can’t be reactivated in a single careless click.
  • Both return a count of affected rows so the client can toast the exact number processed.

Audit logging: All GUI-driven creations, toggles, updates, deletions, and bulk operations log through the unified Logger facade — SecureLogger masks any eiou_* key-id patterns and any sk_* secret patterns, so the audit trail never contains a shown-once secret or correlates a key_id to an action in readable form in app.log.

Routing: Dispatched from Functions.php via an allowlist of action names. A new action must be added to both the in_array($action, [...]) check in Functions.php and the switch ($action) in ApiKeysController::routeAction() — missing it from the allowlist causes Functions.php to fall through and render wallet.html (107 kB of HTML) instead of routing to the controller, which manifests client-side as a silent res.json() rejection.


AltCodeController

Handles status / set / rotate / clear of the user-chosen alternate auth code that complements the seed-derived primary auth code. AJAX only; JSON responses. CSRF validated on every call via Session::validateCSRFToken(..., false) (non-rotating). Set and clear are additionally rate-limited per-IP (5 attempts / 5 min / 15-min block) via the gui_altcode_modify bucket, separate from gui_login so attempts on the two surfaces can’t drain each other.

Method Action Value Description Parameters
status() altCodeStatus Cheap presence probe — does an alt code exist on this node, was the current session authenticated via that alt code, and what is the minimum length the validator enforces. Used by the settings panel to decide whether to render the rotate / clear buttons and whether to lock them. csrf_token
setAlt() altCodeSet Set or rotate the alt code. Requires the primary auth code re-entered in band — not via the sensitive-access grant, and never accepted via the alt code itself. Validates strength through AltCodeValidator (≥12 chars; ≥1 each of upper/lower/digit/symbol; no triple-repeats; no monotonic runs ≥4; not a common-password substring). Refuses outright for alt-authenticated sessions with alt_session_forbidden. primary_authcode, new_alt_code, csrf_token
clearAlt() altCodeClear Remove the alt code. Same primary-required, alt-session-forbidden gating as altCodeSet. primary_authcode, csrf_token

Why primary-only for set/rotate/clear: The alt code may not rotate or clear itself. Otherwise an attacker who learned the alt code (shoulder-surfing, leaked password manager, etc.) could overwrite it with one only they know and lock the legitimate operator out. The seed-derived primary always retains control; recovery from forgetting the alt code is “log in with the primary, rotate the alt.” Recovery from forgetting the primary is the seed phrase, exactly as before this feature.

Constant-time verification (defense in depth): The verifier the controller delegates to (AltCodeVerifier::verify()) always runs an Argon2id check, against a per-process placeholder hash when no real hash is configured. Combined with the equivalent change in Session::authenticate(), this prevents a network observer from inferring alt-code presence by timing failed-login responses (~50 ms with vs ~µs without otherwise).

Rate limit semantics: The alt_session_forbidden refusal fires before the rate-limit gate, so an alt-code-only session can never exhaust the bucket and lock the legitimate operator out of rotating. Bucket is shared between altCodeSet and altCodeClear so attempts can’t be laundered across endpoints.

Self-rotation lockout UX (when authenticated via alt): The settings panel renders an alert-warning callout above the action row explaining the restriction. The Rotate / Remove buttons render with aria-disabled="true" and a lock icon (rather than HTML disabled, which suppresses clicks entirely), so clicks on them route to an explainAltCodeLocked handler that scrolls the warning into view and flashes it — silent dead clicks would be a worse UX than a visible “here’s why”.

Routing: Same dispatcher allowlist as every other AJAX controller — registered with GuiActionRegistry at TIER_AUTH. Reachable only on an authenticated session; index.html short-circuits unauthenticated requests to the login form before the dispatcher runs.


PaybackMethodsController

Handles every AJAX call the dashboard’s Payback Methods section and the contact modal’s Payback tab fire — CRUD for your own methods plus the synchronous E2E fetch used to pull a contact’s shareable methods over the wire. JSON-only; CSRF is validated on every call.

Method Action Value Description Parameters
list() paybackMethodsList List this node’s methods (public shape, sensitive fields masked). Response also carries sensitive_access + seconds_remaining mirroring apiKeysList, so the section header can show “🔓 Unlocked for N min” without an extra round-trip. csrf_token
create() paybackMethodsCreate Create a new method. Validates via PaybackMethodTypeValidator (which delegates to PaybackMethodTypeContract::validate() for plugin-registered types). Returns method_id. type, label, currency, fields[...], optional share_policy, optional priority, csrf_token
update() paybackMethodsUpdate Patch an existing row. Accepts any of label, share_policy, priority, enabled, fields — sending fields re-encrypts the entire blob atomically. method_id, any of label, share_policy, priority, enabled, fields[...], csrf_token
reveal() paybackMethodsReveal Return the method with all fields decrypted to plaintext. Used by Edit (to pre-populate sensitive inputs) and the per-field Copy buttons on the detail modal. method_id, csrf_token
remove() paybackMethodsRemove Permanently delete a method. method_id, csrf_token
setSharePolicy() paybackMethodsSetSharePolicy Atomic share-policy update without touching other fields. method_id, share_policy, csrf_token
fetchFromContact() paybackMethodsFetchFromContact Fire a synchronous payback-methods-request.v1 E2E round-trip at a contact and return their shareable methods inline. Nothing is cached — closing the tab drops the in-memory copy. Returns {status, methods, ttl_seconds}; status is one of ok / denied / rate_limited. Stale responses are dropped if the user switched contacts mid-flight. address, optional currency, csrf_token

Sensitive-access gate: All mutations (Create, Update, Remove, SetSharePolicy) and Reveal require an active sensitive-access grant — the GUI shares the same apiKeysVerify / apiKeysStatus / apiKeysClearAccess mechanism so a single unlock covers both API-key and payback-method edits for the grant’s lifetime. The client routes all seven calls through withSensitiveAccess(requestFn, onResponse, label) which opens apiKeysVerifyModal on a 401 sensitive_access_required response and retries on successful unlock. Listing (paybackMethodsList) and contact-fetch (paybackMethodsFetchFromContact) are outside the gate — list rows already show masked values, and the contact-fetch response is composed by the other node so there’s nothing sensitive for this node to re-gate.

E2E fetch mechanics (fetchFromContact): The controller extracts the inner response body from the envelope that MessageService echoes back from MessageDeliveryService::sendSyncMessage(), normalizes the status strings, and returns them to the GUI. Underlying flow: request → receiver’s ReceivedPaybackMethodService::handleIncomingRequest() → receiver’s PaybackMethodService::listShareable() → response envelope back through the same E2E channel. Nothing is written to payback_methods_received on this flow — that table is reserved for a future cache layer.

Routing: Same allowlist rule as every other AJAX controller — new actions must be added both to Functions.php’s action check and the controller’s switch dispatch.


PluginController

Handles every AJAX call the Plugins tab fires — listing installed plugins, toggling them on/off, the per-plugin public-routes toggle, uploads/upgrades, and uninstall. JSON-only; CSRF is validated on every call via the shared routeAction() dispatch.

Method Action Value Description Parameters
listPlugins() pluginsList List installed plugins with metadata, enabled flag, status, and whether a restart is pending. csrf_token
togglePlugin() pluginsToggle Persist a plugin’s enabled flag (no restart). The response carries the re-classified public_routes_state, which the client adopts before re-rendering the row: a disabled plugin classifies as none, so without it a plugin the operator just enabled would show no Public routes toggle until a reload. When the plugin declares public routes but they won’t be served (this plugin’s toggle off under the default allow ceiling, or the node ceiling explicitly off), the response also carries public_routes_gated: true and public_routes_message, so the GUI raises a warning toast. name, enabled, csrf_token
setPublicRoutes() pluginsSetPublicRoutes The per-plugin Public routes toggle (applies under EIOU_PUBLIC_PLUGIN_ROUTES=allow, the default ceiling). Persists the preference and re-renders nginx immediately. Response carries public_routes_enabled and public_routes_state (live / plugin_off / node_off / none), plus a public_routes_message when the state is node_off. Under the node-wide force-all ceiling (on) the row shows a read-only “on (node-forced)” badge instead of an interactive toggle. name, enabled, csrf_token
requestRestart() pluginsRequestRestart Request a node restart to apply pending plugin state changes. csrf_token
showChangelog() pluginChangelog Return a plugin’s bundled changelog for display in the GUI. name, csrf_token
uninstallPlugin() pluginsUninstall Run the full uninstall sequence (must be disabled first). name, csrf_token
uploadPlugin() pluginsUpload Upload a new plugin archive; staged as disabled. file upload, csrf_token
uploadAsUpgrade() pluginsUploadAsUpgrade Upload an archive as an in-place upgrade of an installed plugin (old version preserved for rollback). file upload, name, csrf_token
upgradeBundled() pluginsUpgrade Upgrade an installed plugin to a newer image-bundled version. name, csrf_token
reportUploadLimits() pluginsUploadLimits Report the effective upload size limits so the client can pre-validate. csrf_token

The owned action names are kept in a single OWNED_ACTIONS constant so the live registration path and the loader-not-ready stub path cannot drift.


Layout Components

authenticationForm.html

Login page displayed when user is not authenticated.

Element Purpose
Password input Auth code entry
Error message Displayed on failed login
Loading overlay Shown during submission

wallet.html

Main layout container that includes all subpart components.

Tab Structure:

Tab Components
Dashboard walletInformation.html
Send eiouForm.html, paymentRequestsSection.html
Contacts contactSection.html
Activity transactionHistory.html, dlqSection.html
Plugins pluginsSection.html
Settings settingsSection.html, debugSection.html

Include Order:

  1. banner.html
  2. header.html
  3. notifications.html
  4. walletInformation.html (Dashboard tab)
  5. eiouForm.html (Send tab)
  6. paymentRequestsSection.html (Send tab)
  7. contactSection.html (Contacts tab)
  8. transactionHistory.html (Activity tab)
  9. dlqSection.html (Activity tab)
  10. pluginsSection.html (Plugins tab)
  11. settingsSection.html (Settings tab)
  12. debugSection.html (Settings tab — appended below settings form)
  13. floatingButtons.html
  14. downloadModal.html (global export modal, hidden until opened)
  15. analyticsConsentModal.html

walletSubParts Components

banner.html

Loads and displays banner images from /gui/assets/banners/. Any image placed in that directory (jpg, jpeg, png, gif, svg, webp) is shown at the top of the wallet page. Files are sorted alphabetically. Used for promotional or informational banners.

Element Purpose
Banner carousel Displays images from the banners directory

header.html

Element Purpose
Wallet title Branding with icon
Download icon (data-action="openDownloadModal") Opens the global Download modal — single entry point for exporting transactions / payment requests / contacts / balances. See downloadModal.html.
Logout link Ends session

downloadModal.html

Global export entry point — triggered by the Download icon in the wallet header (and also from a smaller Download icon inside the contact modal’s Transactions tab, which pre-fills the modal with entity=transactions and the open contact’s pubkey hash).

Element Purpose
Entity radio Pick what to export: Transactions / Payment requests / Contacts / Balances. Filter blocks below show/hide based on selection (syncDownloadModalFilters()).
Contact picker Server-side rendered <select> populated from ContactRepository::getAcceptedContacts() at modal-template render time. Shared by transactions / payment_requests / balances. Pubkey-hash values; no fetch round-trip (Tor Browser compatible).
Per-entity status select Different enums per entity (transactions = pending / sending / sent / accepted / rejected / cancelled / completed / failed; payment_requests = pending / sending / approved / failed / declined / cancelled / expired; contacts = accepted / pending / blocked). On submit the visible block’s value writes back to a shared status query param.
Transaction direction / payment-request direction Direction filters; per-entity to match the entity-specific column (txns use type, payment_requests use direction).
Currency text input Balances only. Free-text 3-10 char currency code.
Date range UTC from / to date inputs. Only shown for transactions + payment_requests. to is autofilled with today (UTC) on every modal open; from stays empty so the default behaviour is “all history up to today”.
Format radio CSV (Excel/Sheets, UTF-8 BOM, RFC 4180 quoting) or NDJSON (one JSON object per line).
View radio Slim (accountant-friendly column set) or Full (audit-grade, includes addresses, signatures, internal fields).
Download button (data-action="triggerDownload") Walks the visible filter blocks, builds a /gui/export.html?entity=…&… URL, and navigates the browser there. The response’s Content-Disposition: attachment header turns the navigation into a file download — Tor Browser compatible (no fetch / Blob / objectURL path).
Cancel button Closes the modal.

Reset on open: resetDownloadModalForm() runs at the top of every openDownloadModalWithPrefill() so the modal starts from a clean baseline regardless of any previous selections (entity → transactions, every select to its first “Any …” option, every text/date input cleared, format → CSV, view → slim, to → today UTC). The contact-modal entry then applies its entity + contactPkh prefill on top.

Stacking when opened from another modal: the contact-modal Download icon (data-action="openDownloadModalForContact") opens this modal with .modal-stack-top + bringModalToTop() so it renders above the contact modal; the class drops in closeDownloadModal.

Backend entry point: /app/eiou/www/gui/export.html — session-authenticated PHP entry point (CSRF not required, read-only). Accepts the modal’s query params (and contact=NAME as a CLI-parity alternative to contact_pubkey_hash=), validates, opens php://output, calls ExportService::export(). See docs//docs/reference/cli-reference (Export Commands) and docs//docs/reference/api-reference (Export Endpoints) for the same pipeline’s CLI and REST faces.


notifications.html

Element Purpose
Operation result toasts Success/error messages from redirects
Tor connectivity status Warning when Tor is unreachable, success toast when restored
Update available banner Shows current vs available version with Docker pull command
What’s New banner After upgrade, shows “See what’s new in vX.X.X” link that opens a release notes modal; dismissible per-version
In-progress banner Shows pending transaction count
Pending contacts banner Shows pending contact request count
Pending currency requests Shows incoming currency requests from existing contacts
Tx drop proposal banner Incoming proposals requiring action (red alert)
Completed transaction toasts Notifications for finished transactions
Received transaction toasts Notifications for incoming payments
DLQ notifications Dead letter queue failure alerts

quickActions.html

Note: This component is no longer included in the dashboard layout. Navigation is now fully tab-based. The file is retained but unused.


walletInformation.html

Element Purpose
Last updated timestamp + Refresh link Shows data freshness with manual refresh
Total Balance Aggregated wallet balance per currency (blue card)
Total Fee Earnings P2P relay fee earnings per currency (amber/gold card)
Total Available Credit Sum of available credit per currency (blue-purple card), from ping/pong, ~5 min refresh
User Addresses HTTP/HTTPS/Tor with Copy and QR code buttons
Scan Contact QR Opens camera scanner — on scan, switches to Contacts tab and opens Add Contact modal with pre-filled address and name
Public Key Wallet public key with copy button
Status Always “Active”

All three dashboard cards display per-currency rows. When a card has no data for a given category, it shows “0.00” with the currency derived from other data sources for consistency.

The ⓘ icons next to “Total Fee Earnings” and “Total Available Credit” open a small info modal on click (tap-friendly on mobile).

QR Code Format: QR codes use a typed JSON envelope for forward compatibility:

Type Format Description
contact {"type":"contact","address":"...","name":"..."} Add contact (name optional)
payment {"type":"payment","address":"...","amount":...,"currency":"...","description":"..."} Payment request (future)

Legacy plain-text QR codes (just an address string) are parsed as type contact for backward compatibility.


eiouForm.html

The New eIOU card leads with two sub-tabs under its header (Send eIOU and Request Payment), backed by a single shared recipient picker. Send is the universal send (a saved contact or a raw P2P address); Request Payment is contact-only and hides the send-only fields.

Field Type Modes Description
recipient hidden both Selected contact name (set by the recipient search)
address_type select both Address type for the selected contact (options sorted Tor > HTTPS > HTTP, most-secure auto-selected)
manual_recipient text send only Direct address entry for P2P routing to a non-contact
amount number both Transaction amount
currency select both Currency code (user’s allowed currencies; filtered to the contact’s accepted currencies once a contact is selected)
description text both Optional memo
best_fee checkbox send only [Experimental] Best-fee routing: collects all route responses and selects the lowest fee

Sub-tab behaviour (setSendMode):

  • Send mode points the form at sendEIOU, shows the manual-address entry (when no contact is picked) and the Best Fee Route toggle, and shows the Send eIOU submit.
  • Request mode points the form at createPaymentRequest, hides the manual-address and Best Fee Route fields, swaps in request-specific hint text that makes no reference to P2P routing or relay nodes (a request is a direct, contact-only message), and shows the Request Payment submit. That submit is disabled until a contact is selected, and runs the browser’s native field validation before submitting (parity with the Send submit).
  • Elements that differ by mode carry .send-mode-only / .request-mode-only; setSendMode toggles their inline display (inline rather than a .d-none class because .form-hint’s display rule would otherwise win the cascade).

Features:

  • Dynamic address type selector (options sorted by security preference: Tor > HTTPS > HTTP, most-secure auto-selected)
  • Dynamic currency dropdown: all allowed currencies when no contact selected, filtered to the contact’s accepted currencies once one is
  • Opened prefilled from a contact via the contact modal’s Send eIOU / Request Payment buttons (sendToContact / requestFromContact → openSendFormForContact), which select the matching sub-tab and focus Amount

paymentRequestsSection.html

Rendered below the Send form in the Send tab. Shows incoming and outgoing payment requests.

Pending Requests callout: When there are any pending requests (incoming + outgoing combined), they render inside a .pending-callout .pending-callout-has-body yellow card with a header showing the count and direction breakdown. The shared .pending-callout-* classes are reused by dlqSection.html for the same visual treatment on Failed Messages — both sections present “you have N pending items” with identical chrome (yellow gradient, count chip, embedded white-row table).

Incoming Requests (pending):

Element Purpose
Requester name/address Who sent the request
Amount + currency Requested amount
Description Optional note from requester
Pay button Opens a confirmation modal pre-loaded with the requester’s description (read-only) and an optional payer-note textarea. The on-chain description becomes "payment: <requester desc> | <your note>" if the note is filled. A live counter under the textarea shows remaining chars and decrements as the user types — the cap is computed dynamically against this specific request’s description (max_note = 255 − len(desc) − 3) so the joined string always fits the on-chain ceiling. If the requester’s description already fills the budget, the textarea is disabled and the user can still hit Pay (with no note). Same modal is used whether the user clicks Pay on the inline pending-row form or the Pay button inside the row-click “Payment Request Details” drill-down modal — the submit handler is event-delegated so dynamically-rendered Pay forms get caught too.
Decline button Rejects the request, notifies requester

Outgoing Requests (pending):

Element Purpose
Recipient name/address Who you sent the request to
Amount + currency Requested amount
Description Optional note
Status badge Pending / Approved / Declined
Cancel button Cancels the outgoing request

Resolved requests (approved/declined) appear in a collapsed history section. Approved requests show a clickable truncated txid that opens the transaction detail modal.

History Paginator (resolved requests only):

  • Same shared Paginator IIFE as the Recent Transactions and Contacts tables. Page buttons + size selector (25 / 50 / 100 / All), persisted as eiou_paginator_size_payment-requests.
  • “Load older” button — fetches next server-side page via loadMorePaymentRequests GUI AJAX action. Backed by PaymentRequestRepository::getResolvedHistoryPage($limit, $offset, ?$cursor). When the client posts a cursor (default for second-and-later pages), the query uses a keyset predicate on (COALESCE(responded_at, created_at), id) and runs in constant time regardless of depth; without a cursor it falls back to LIMIT ? OFFSET ? on the same ORDER BY. Sort key matches the initial template’s usort so pages append cleanly. Rows rendered via a shared _paymentRequestRow.html partial.
  • “Showing the last N requests” counter is dynamic via #pr-meta-loaded-count; updates after each Load-older click.

Search database (server-side):

  • Same pattern as Recent Transactions — button next to the search input, or Enter in the search box. Fires searchPaymentRequests GUI AJAX action → PaymentRequestRepository::searchResolvedHistory (LIKE across contact_name, description, requester_pubkey_hash, recipient_pubkey_hash, requester_address).
  • Respects the direction / status filter selects. Hard-capped at 500 rows; response includes {html, total, capped, cap}.
  • Result rows replace the tbody; banner shows N matches for "term" · Clear search; Clear reloads the page.

contactForm.html

Note: This file contains the Add Contact form fields. It is included as part of the Add Contact modal inside contactSection.html, not as a standalone include in wallet.html.

Field Type Description
address text Contact node address (placeholder: “Enter Tor (.onion) or HTTP(S) address”, with QR scan button)
name text Display name
credit number Credit limit (default from settings)
fee number Fee percentage (default from settings)
currency select Currency code (dynamically populated from user’s allowed currencies)
description text Optional message sent with the contact request (max 255 chars). For non-Tor contacts, sent as a separate E2E encrypted follow-up after key exchange. For Tor contacts, included directly (protected by Tor transport encryption)

contactSection.html

The Contacts tab. The contact list is shown first. The “Add Contact” form is accessed via the “+ New Contact” button which opens a modal dialog (the form fields from contactForm.html are embedded in this modal, not rendered inline).

Contact Grid:

  • Accepted contacts with balance
  • Pending user contacts (outgoing)
  • Blocked contacts
  • Search/filter functionality
  • Show more toggle (>16 contacts)
  • Shared paginator — page-size selector (25/50/100/All, persisted per-table via safeStorageSet under eiou_paginator_size_contacts) + prev/next buttons + range indicator, installed by Paginator.create('contacts', …) in script.js. Orthogonal .filter-hidden / .paginator-hidden classes on rows so the local search + status/chain/online filters keep working in combination with pagination
  • “Load more” button (accepted contacts only — pending + blocked are always rendered up-front because they’re bounded and operationally important). Fetches the next server-side page via the loadMoreContacts GUI AJAX action, rendering rows through a shared _contactRow.html partial so appended rows are byte-identical to the initial render
  • Tx drop proposal badges on contact cards:
    • Red “Action Required” — incoming proposal needs acceptance
    • Blue “Awaiting Response” — outgoing proposal pending
    • Orange “Blocked” — rejected proposal, transactions blocked
    • Yellow triangle — chain invalid, no proposal yet

Contact Modal (Tabbed):

Tab Contents
Info Per-currency balance, credit limit, fee, your/their available credit (via horizontal currency slider pills), addresses (with Copy and QR code buttons; QR regenerates when switching address types), public key (single-line display with Copy button), contact ID. A Send eIOU / Request Payment button pair sits directly under the balance (accepted contacts only); each opens the New eIOU form on the matching sub-tab with this contact prefilled and Amount focused.
Transactions Recent transactions with this contact. A small Download icon (data-action="openDownloadModalForContact") sits next to the Refresh button — opens the global Download modal with entity=transactions and this contact’s pubkey hash pre-selected.
Payback Live E2E fetch of the contact’s shareable payback methods (bank wire, PayPal, BTC, etc.). Opens a synchronous payback-methods-request.v1 round-trip through the node; response is rendered inline and never persisted to disk or localStorage. Currency dropdown defaults to the currency of the largest debt owed, filters the fetch when changed (auto-refetches). Rows are clickable → per-method detail modal with per-field Copy buttons. Renders dedicated copy for denied / rate_limited states.
Status Online status, chain status (proposal-aware, clickable — switches to this tab), Check Status button, tx drop resolution section (propose/accept/reject)
Settings Edit form, block/unblock/delete buttons

Every tab has a collapsible About info panel at the top explaining what the columns/fields mean.

Tx Drop Resolution Section (in Info tab):

  • Shown when chain has a gap or active proposal
  • Sub-sections: propose, awaiting acceptance, incoming (accept/reject), rejected (repropose)
  • Funds warning: dropping a transaction removes its funds from both balances
  • Chain status badge clicks scroll to this section

Pending Contact Requests Section:

  • Lists incoming requests with direction-aware currency display
  • Outgoing currencies shown as read-only badges (“Awaiting their acceptance”)
  • Incoming currencies shown in a batched-decision modal (“They requested”):
    • Each currency renders as an accordion with a 3-state segmented control (Accept / Decline / Defer) and fee/credit inputs visible only when Accept
    • Smart default: Accept for currencies in the user’s allowed list, Defer for “new” currencies (not in Allowed Currencies)
    • State badge in each accordion summary (✓ Accept / ✗ Decline / ⏳ Defer) — visible without expanding
    • Accordion auto-collapses on Decline/Defer (nothing to configure), re-opens on Accept
    • Apply button at the bottom commits everything in a single applyContactDecisions POST. Label updates live: “Accept request” / “Accept all N currencies” / “Decline N” / “Apply: X accept, Y decline”; disabled when every row is Defer
    • Defaults guard: one confirmation modal if any Accept rows are at both default fee + default credit
    • Inline help disclosure (“How to handle this request”) collapsed by default at the top of the modal
    • “About this contact” reference section below the action area (addresses, public key)
  • Delete Request silently drops the whole request (other side not notified). Block Contact prevents future requests
  • Legacy fallback form preserved for contacts without contact_currencies data

transactionHistory.html

In-Progress Section:

  • Phase indicators (pending, route_search, route_found, sending, syncing)
  • Direct badge shown for non-P2P transactions (P2P type is implied by route/fee info)
  • Held transaction notices
  • P2P approval gate: when autoAcceptTransaction is OFF, transactions pause at awaiting_approval (blue badge) with route selection UI. Send amount shown inline before candidates
    • Fast mode: shows 1 route with fee breakdown, Accept/Reject buttons
    • Best-fee mode: lists all returned routes ordered by fee (lowest first), user picks one. On mobile (≤576px) candidate rows stack vertically
    • Route count updates dynamically as late-arriving candidates are received
    • Node fee hidden during awaiting_approval (route fee shown per-candidate instead)

Transaction Table Columns:

Column Description
Status icon Leading column, no header. Single check (sent/accepted), double check (completed), hourglass (pending), cross (rejected), ban (cancelled). Muted grey for normal states; amber for pending, red for rejected
Counterparty Avatar + contact name
Amount Sortable. ±value carries sent/received direction
Description CSS-truncated; full text on hover
Type Direct / P2P / Contact badge
Origin / Dest. P2P only: final destination (sent) or original sender (received)
Date Sortable, formatted timestamp
  • Type badges (Contact, P2P, Direct)
  • Click any row to open detail modal

Paginator:

  • Shared Paginator IIFE in script.js — same infrastructure Contacts and Payment Requests history use. Page-size selector (25 / 50 / 100 / All, persisted per-table), prev/next buttons, range indicator (“101–125 of 200”).
  • “Load older” button — fetches the next server-side page via the loadMoreTransactions GUI AJAX action, rendering rows through the _transactionHistoryRow.html partial that both the initial foreach and the AJAX handler consume. Client concatenates the returned data.rows onto the in-memory transactionData[] so openTransactionModal(index) keeps resolving for appended rows. Button auto-hides when the server reports exhausted: true.
  • “Showing the last N transactions” counter updates dynamically as rows are loaded or replaced. Uses a #tx-meta-loaded-count span rewritten by refreshMetaLoadedCount().

Search database (server-side):

  • Button next to the search input, also triggered by pressing Enter in the search box.
  • Fires searchTransactions GUI AJAX action — backend runs a case-insensitive LIKE %term% across counterparty name (direct + P2P end-recipient + P2P initial-sender), transaction description, all four address fields (sender/receiver/end-recipient/initial-sender), and the txid. Pasting any full or partial hash surfaces the matching row, whether it’s currently loaded in the paginator window or not.
  • Respects the current direction / type / status filter select values.
  • Hard-capped at 500 rows; response carries {html, rows[], total, capped, cap} so the banner can disclose when a broad query was clipped.
  • Result rows replace (not append to) the table body. Paginator moves to page 0, load-older is suspended (the result set is bounded by the server cap). Banner shows N matches for "term" · Clear search which reloads to the default view.
  • The local search input’s live-keystroke filter also searches the P2P endpoint name/address via new data-tx-endpoint-name / data-tx-endpoint-address row attributes and the txid (via data-txid, which was already present on the row for the detail-modal cross-link) — so typing “carol” or pasting a txid locally matches the same rows the database search would return for already-loaded transactions, and the “X transactions found” counter stays consistent after a database search.

Transaction Modal:

  • Full transaction details
  • Copy buttons for addresses/txid

Auto-Refresh:

  • Polls every 15 seconds when transactions pending
  • Controlled by autoRefreshEnabled setting

dlqSection.html

The Dead Letter Queue section displays messages that could not be delivered after all automatic retry attempts.

Pending Messages callout: When the queue has any pending or retrying items, the table renders inside a .pending-callout .pending-callout-has-body yellow card with a Pending Messages [count] (X retryable, Y abandon-only) header. Shared chrome with paymentRequestsSection.html’s “Pending Requests” callout — same .pending-callout-* classes, same yellow-gradient card with white table rows. When no pending items exist, the table renders normally with no callout. (Previously the pending count was a free-floating badge in the section header; that badge is gone, though the .dlq-pending-badge CSS class survives because the dashboard quick-actions tile still uses it.)

Status Filter: Dropdown matching contacts/transactions pattern — Any status (default), Pending & Retrying, Pending Only, Resolved, Abandoned. The search bar and filter are both gated on !empty($dlqItems) — an empty queue hides them so users aren’t shown controls with nothing to act on (matches the Contacts and Payment Requests sections).

Stats Bar: Per-status counts (Pending / Retrying / Resolved / Abandoned).

Table Columns: Status icon (leading, slim), Type, Recipient, Failure Reason (truncated), Added, Actions. Uses contacts-table chrome with 60vh scrollable wrapper. Clicking any row opens a detail modal with all fields + Retry/Abandon buttons. The leading status-icon column carries pending / retrying / resolved / abandoned state via fa-hourglass-half / fa-sync-alt fa-spin / fa-check-double / fa-ban — resolved and abandoned colours follow the user-selected status colour scheme. The legacy dedicated “Status” column was removed: the Actions cell carries “Delivered” / “Abandoned” text for terminal rows and the icon + row-click modal cover the rest.

Mobile (≤600px): Shows status icon + Type + Recipient (the generic .contacts-table rule hiding cols 3+ is overridden here so the recipient stays visible alongside the icon column). Tap row for detail modal with full info and actions. Action buttons collapse to icon-only at ≤900px.

Actions per row:

Action Available For Description
Retry transaction, contact types only, pending/retrying status Re-sends the original signed payload directly to the recipient
Abandon Any pending/retrying item Marks as abandoned — no further retries
Retry All Bulk action — all retryable items Re-sends all eligible items (transaction + contact types only)
Abandon All Bulk action — all pending/retrying items Marks all actionable items as abandoned

Important — retry eligibility by message type:

Type Retryable Reason
transaction ✅ User-initiated payment; payload remains valid
contact ✅ User-initiated contact request; payload remains valid
p2p ❌ P2P routing request; expires in ≤300s — stale by retry time
rp2p ❌ Relay message forwarded on behalf of another node; underlying P2P has expired or resolved elsewhere

p2p and rp2p items show an “Expired” label instead of a Retry button. Use Abandon to clear them.

Tab Badge: When pending DLQ items exist, the Activity tab in the navigation bar displays a count badge.

DLQ Indicator in Transaction History: Transactions that have a pending or retrying DLQ entry display a DLQ icon next to the status icon in the Recent Transactions table and a DLQ badge in the In-Progress Transactions list. Clicking the icon/badge navigates to #dlq to retry or abandon the delivery. When a transaction’s delivery is exhausted and it moves to the DLQ, its status is immediately set to cancelled so it is removed from the In-Progress panel and stops triggering auto-refresh. Retrying from the DLQ resets the status to sending and re-delivers the original signed payload.

Mobile Layout (≤600px): Collapses to three columns: Status icon | Counterparty | Amount. The +/− on Amount carries direction; the status icon confirms completion state.

Notifications: A warning toast appears when new items are added to the DLQ (tracked per session — each item fires once per browser session).


paybackMethodsSection.html

Dashboard section rendering the user’s own payback methods — the settlement rails (bank wire, PayPal, BTC, custom free-text, etc.) they offer contacts for squaring debts. Uses the same form-container fade-in-up chrome as Payment Requests.

Header:

  • + Add button — opens the two-step Add/Edit modal (paybackMethodForm.html)
  • 🔓 Unlocked for N min — full-width muted status row rendered above the section body when a sensitive-access grant is active (hidden when locked). Server-side initial state is rendered straight from Session::sensitiveAccessSecondsRemaining(), and a top-level updateSensitiveAccessBadges() helper in script.js keeps every visible row across the page in sync from a single source — unlocking from any verify modal refreshes all badges, not just the section that triggered the prompt

Header callout — “How payback methods work”: per-row encryption at rest (AES-256-GCM, keyed to the wallet), how each rail’s masking differs (typed rails show last-4; custom shows the first 80 chars as a preview since it’s user-authored free text), share policy behaviour.

Filter bar: client-side Type and Currency dropdowns reusing the shared .contacts-filter-select chrome from Recent Transactions. Only surfaces when >1 option exists on an axis — a single-method / single-currency wallet sees no dead widgets.

Table columns: Type (plain uppercase, no badge), Currency, Label, Details (the masked_display), Share policy. Ordered by priority ASC, created_at DESC — same sort the repository uses. Row click opens the view modal.

Add / View / Edit modal (paybackMethodForm.html) is a two-step flow:

  1. Step 1 — Type picker. Tiles injected from the catalog JSON at #payback-methods-catalog (the PHP side echoes the full PaybackMethodTypeValidator::getCatalog() result, including plugin-registered types). Tiles group by bank / crypto / p2p / other — plugin-declared groups are slotted in automatically between the pre-declared groups, with other always last.
  2. Step 2 — Per-type fields. Form body is built from the catalog entry’s fields spec: label input, currency select (filtered to type.currencies when the type declares a whitelist), type-specific inputs (IBAN, routing number, email, address…), share-policy select, priority number input. SWIFT sub-rail gets a segmented [IBAN] [Account number] toggle so only the chosen identifier submits.

View mode renders the same DOM with inputs set to readonly and a “View only. Click Edit below to make changes” banner; footer swaps to Close · Edit. Edit mode flips inputs to editable without refetch (title changes, banner hides, footer swaps to Cancel · Delete · Save Method).

Sensitive-access gate: opening the modal for edit/view — and every mutation (add, update, remove, share-policy, reveal) — returns 401 sensitive_access_required unless a short-lived grant is active. The client routes through the same withSensitiveAccess(requestFn, onResponse, label) helper that apiKeysSection.html uses, so a denied request opens apiKeysVerifyModal on top of the form and retries on successful unlock. Unlock persists for a few minutes; the section’s “Unlocked for N min” badge appears as a full-width muted row above the body content while the grant is active.

Per-rail “About ” info panel at the top of step 2, populated from the catalog entry’s optional info HTML string. Starts collapsed so returning users who don’t need the refresher see the compact form. Plugins opt-in by returning an info key from getCatalogEntry() — a BTC plugin can call out accepted address formats, a PayPal plugin can remind operators to link an active account, etc.

Client-side module (script.js, IIFE exported as window.paybackMethods) talks to PaybackMethodsController via paybackMethodsList / paybackMethodsCreate / paybackMethodsUpdate / paybackMethodsRemove / paybackMethodsReveal / paybackMethodsSetSharePolicy AJAX actions. Session-expired responses (302 / 401 / HTML login page) are caught in the JSON-parse branch and surfaced as a readable “Session expired — please sign in again” message instead of a cryptic bad_json toast.

Related surfaces:

  • Contact modal Payback tab (contactSection.html) — live E2E fetch of the other contact’s shareable methods; nothing persists. See the Contact Modal table above.
  • PaybackMethodsController — see Controllers section below.

settingsSection.html

Header callout — .section-intro explaining what Save does, what Reset reverts (unsaved changes only), and pointing at Advanced Settings → Reset to Defaults for a full wipe.

Settings Form:

  • Basic wallet settings organized into four <h5 class="settings-group-heading"> subsections — Appearance (contact avatar style, amount and status color schemes), Identity (display name), Payments (default currency, fees, credit limit), Network (default transport mode)
  • Unified Settings category dropdown. Categories: Appearance, Identity, Payments, Network, Notifications, Feature Toggles, Currency, Display, Backup & Logging, Data Retention, Sync, Network (advanced), Rate Limiting, GUI Security, Reset to Defaults
  • Advanced categories with >3 fields are further subdivided into <h5 class="settings-group-heading"> groups: Feature Toggles (Contacts, Transactions, GUI, System), Backup & Logging (Backup, Logging), Data Retention (Cleanup, Archive), Rate Limiting (Throughput, Attempt Blocking), Network (Transport Timeouts, Tor Resilience, Routing & Delivery, API). Each subsection is its own .settings-grid so the 3-column auto-fill layout applies per group
  • GUI subsection hosts autoRefreshEnabled and hideEmptyGuiSections. hideEmptyGuiSections (default OFF) hides the Failed Messages / Payment Requests / Pending Contact Requests sections when their lists are empty — when OFF (the default) these sections render an empty-state panel so users know the feature exists
  • GUI Security category hosts Session Timeout (moved here from the main grid), Remember Me Duration, Max Remembered Devices, and the Active Remembered Sessions list. The sessions list heading uses settings-group-heading for cross-category consistency, and the empty state uses the shared .empty-panel (dashed border + centered text) that also backs the API Keys empty state
  • Backup & Logging includes an encrypted-backup table with filename, scheduled/archive-triggered origin, size, creation time, and Create / Download / Verify actions. List is CSRF-gated; the other actions require the short-lived auth-code unlock. Downloads stream the encrypted artifact as an attachment; database restore is not exposed in the GUI.
  • Data Retention category has two subsections with distinct semantics: a Cleanup block (cleanupDeliveryRetentionDays, cleanupDlqRetentionDays, cleanupHeldTxRetentionDays, cleanupRp2pRetentionDays, cleanupMetricsRetentionDays) where rows past retention are deleted, and a separate Archive block (paymentRequestsArchiveRetentionDays, paymentRequestsArchiveBatchSize, transactionsArchiveRetentionDays, transactionsArchiveBatchSize) where resolved payment requests and eligible gap-free transactions move into their archive tables and stay queryable. The archive block carries its own inline warning (“nothing is deleted”) so users don’t confuse it with the cleanup retentions above it
  • Reset to Defaults category is a dedicated destructive-action surface — danger button opens settingsResetToDefaultsModal which requires typing reset into a confirmation input before the submit button enables. Submits to SettingsController::handleResetToDefaults() via a separate form (outside the main settings <form>, since a nested form isn’t legal HTML)
  • Save / Reset buttons at the bottom — Save posts updateSettings via XHR (the form’s native submit is intercepted by submitSettingsForm() in script.js); Reset is a plain <button type="reset"> that rolls back unsaved form state
  • 🔓 Unlocked for N min — full-width muted status row above the form, in sync with the same row in API Keys and Payback Methods (every section that gates on a sensitive-access grant). Hidden when no grant is active

updateSettings is AJAX, not a full-page POST. The action registers at TIER_SENSITIVE so the registry’s pre-dispatch handles both the CSRF check and the sensitive-access gate as a JSON 403 sensitive_access_required response. submitSettingsForm() catches that envelope, opens apiKeysVerifyModal for re-auth, and re-submits the same form once the user verifies — the user never loses their unsaved field edits to a session-grant lapse. Validation errors return 400 with {success: false, errors: […]} and surface as a toast; save success returns {success: true} and the page reloads to re-render any computed labels and rotate the page-load CSRF token. Pre-AJAX, the form was a full-page POST that on a sensitive-access lapse just redirected with a “Please re-enter your auth code” toast and no way for the user to actually re-enter it without abandoning the page.

The Settings tab additionally hosts altCodeSection.html (alternate auth code management, see below), apiKeysSection.html (API-key lifecycle, see below), and debugSection.html (debug logs, system info, debug report).


altCodeSection.html

Rendered on the Settings tab, between the main settings form and the API Keys section. Exposes status / set / rotate / clear for the user-chosen alternate auth code. All form submissions go through the controller surface documented under AltCodeController; this entry covers the user-facing components.

Status row (always shown):

  • An alternate auth code is currently configured. (green check) — when UserContext::hasAltCode() is true
  • No alternate auth code is set. (muted dot) — otherwise

Action row:

  • Set alt code (key icon, success-coloured) — opens altCodeSetModal (rendered as Rotate alt code when one already exists)
  • Remove alt code (trash icon, danger-coloured) — opens altCodeClearModal; only shown when an alt code is currently configured

Self-rotation lockout (when session was authenticated via the alt code itself):

  • A full-width .alert-warning callout renders above the action row explaining the lockout in three parts: a bold “Locked this session” headline, the reason (preventing alt-code-holders from rotating to lock out the legitimate operator), and the recovery path (“log out, log in with the primary auth code”)
  • The action buttons render with aria-disabled="true" (not the HTML disabled attribute) and a lock icon replacing the usual key/trash. Visual cues: opacity: 0.55, cursor: not-allowed, native title tooltip
  • Clicking a locked button does not silently fail — it routes through an explainAltCodeLocked data-action that scrolls the warning callout into view (scrollIntoView({ behavior: 'smooth', block: 'center' }), with a hard-jump fallback for older Tor Browser builds) and pulses a yellow box-shadow on it for 0.9 s. Repeated clicks always re-trigger
  • The server-side gate in AltCodeController enforces this independently — the UI lockout is UX-only; even a hand-crafted POST gets refused with alt_session_forbidden

Modals:

  • altCodeSetModal — three fields: Primary auth code, New alt code, Confirm new alt code. Inline strength-meter beneath the new-code input mirrors the server-side rules in AltCodeValidator (length floor, character classes, triple-repeat / sequence detection). The meter is advisory only — the server is authoritative; this is so the user knows what’s missing before submitting
  • altCodeClearModal — single field: Primary auth code. Both modals are minimal — no live status polling, no per-field reveal toggles, no remember-me on the primary input (this is a high-stakes one-shot, not a routine login)

Client-side glue: Self-contained inline <script nonce> block at the bottom of the section file rather than an addition to script.js — keeps the initial cut localised; if the feature grows it’ll be promoted out. Uses only vanilla DOM APIs (no async/await, no fetch shorthand) for Tor Browser “Safer” / “Safest” compatibility. CSRF token is read from the page-level hidden input on every submit; status code 200 with {success: true} reloads the page, anything else surfaces r.data.error (and r.data.errors[] for the validation-failure shape) into the modal’s inline error slot.


.section-intro — shared top-of-section callout

Any section can start with a <details class="section-intro text-muted"> callout:

<details class="section-intro text-muted">
    <summary><i class="fas fa-info-circle"></i> <span>Short title</span></summary>
    <div class="section-intro-body">Longer explanation…</div>
</details>

Currently applied to: Wallet Settings, API Keys, Failed Messages, Debug Information, New eIOU, Your Contacts, Recent Transactions, Payback Methods, Plugins, and every tab inside the contact modal (Info, Transactions, Payback, Status, Settings) — per-tab “About ” intros help users who land on a tab cold.

Always ships closed (no open attribute) — user clicks the summary to expand via native <details> behaviour. Same UX on desktop and mobile and for JS-disabled users (Tor “Safest” mode). The summary shows an info icon on the left and a chevron on the right that rotates when open. CSS lives at .section-intro / details.section-intro > summary / .section-intro-body in page.css.


apiKeysSection.html

Rendered on the Settings tab, between the settings form and the debug section. Lists every API key on the node and exposes the full lifecycle (create / enable / disable / edit / delete / bulk-disable / bulk-delete) without leaving the page.

Toolbar:

  • Create API Key — opens the create modal
  • Refresh — re-fetches the list without a full page reload
  • Disable all — visible only when ≥1 key is enabled
  • Delete all — visible only when ≥1 key exists (enabled or disabled)
  • 🔓 Unlocked for N min — full-width muted status row above the body content while a sensitive-access grant is active (hidden when locked). Same row treatment as Payback Methods and Wallet Settings; kept in sync across all three sections by the page-wide updateSensitiveAccessBadges() helper

List rows (one per key):

  • Label (bold) + Active/Disabled badge
  • key_id (monospace)
  • Meta line: permission summary (comma-joined), rate limit, created timestamp, last-used timestamp, expiration (if any)
  • Actions: Enable/Disable (toggles single key), Edit (label / rate limit / expiry), Delete (typed-confirmation)

Modals (all position: fixed, stacked on .modal z-index 10000 except re-auth at 10010):

  • apiKeysCreateModal — label, permission checkboxes grouped by scope (Wallet / Contacts / System / Backup / Admin), Read-only / Full access / Clear presets, rate limit (1–ApiKeyService::MAX_RATE_LIMIT, default 100), expiry (Never / 30d / 90d / 1 year)
  • apiKeysRevealModal — one-time secret display with per-field Copy buttons and a mandatory “I’ve saved…” acknowledgement before Done. Secret is scrubbed from the DOM the moment this modal closes
  • apiKeysEditModal — read-only permission chips + editable label / rate-limit / expiry-shortening. Live warning when rate limit is raised above the stored value
  • apiKeysDeleteModal — typed confirmation (must match the key’s label)
  • apiKeysDisableAllModal — one-click confirmation with the exact active-key count
  • apiKeysDeleteAllModal — typed confirmation (must type delete all verbatim)
  • apiKeysVerifyModal — sensitive-action re-auth prompt (auth code input + Unlock). Opens on top of whichever modal triggered the gate

Client-side module (script.js, IIFE exported as window.apiKeys) handles dispatch through a shared withSensitiveAccess(requestFn, onResponse, label) helper: on a 401 sensitive_access_required response the module opens the verify modal, and on successful verify retries the original request with the same onResponse handler so the caller doesn’t have to know about the re-auth dance.


debugSection.html

Rendered at the bottom of the Settings tab (below the settings form).

Debug Logs (Tabbed):

Tab Contents
App Logs Debug entries from database
eIOU Log /var/log/eiou/app.log
PHP Logs PHP error log
nginx Logs nginx error log
Processes PID-to-processor lookup table — friendly name + current PID + status (Running / Stale lockfile / Stopped) for the four long-running processors (Transaction, P2P, Cleanup, Contact Status). Each row is a <details> so the role description collapses on mobile; a top-level “About running processes” <details> collapses the cross-reference help. Status comes from posix_kill($pid, 0) against the PID in each processor’s /tmp/*.pid lockfile. Short-lived P2pWorker forks are intentionally not listed (they exit in seconds).
System Info PHP version, extensions, config files, constants

Debug Report:

  • Limited report: Same data as GUI display
  • Full report: Complete log history

floatingButtons.html

Button Purpose
Back to top Scroll to page top
Manual refresh Reload wallet data

analyticsConsentModal.html

One-time modal shown after first login to ask the user whether to enable anonymous analytics. The choice is saved via the analyticsConsent AJAX action and the modal never reappears (analyticsConsentAsked flag in config).

Element Purpose
Consent modal Opt-in/opt-out choice for anonymous analytics
Enable button Sets analyticsEnabled=true
Decline button Sets analyticsEnabled=false

firstLoginSeedModal.html

Shown once, right after a wallet is first generated, so the operator can back up their 24-word seed phrase. The CLI hands the plaintext mnemonic to the GUI through a randomly-named, owner-read-only file on tmpfs (/dev/shm, never written to disk, never decrypted from the stored config); the GUI reads it only behind an authenticated session and renders the words in this modal. The modal keeps appearing until the operator confirms via the firstLoginSeedAck action (a CSRF-gated POST that works with JavaScript disabled), at which point the handoff file is deleted and the phrase can never be displayed here again. A 24-hour backstop deletes the handoff for operators who only ever use the CLI. Restores skip the handoff (the operator already holds the seed). The page that embeds the words is no-store like the rest of the authenticated GUI.

Element Purpose
Word list The 24 mnemonic words, numbered
Confirm checkbox Native required checkbox; gates the Done button without JS
Done button Posts firstLoginSeedAck, which deletes the handoff and dismisses the modal

Session Management

The Session class provides secure session handling.

Session Configuration

Parameter Value Description
lifetime 0 Session cookie (expires on browser close)
httponly true Prevent JavaScript access
samesite Strict CSRF protection via cookie
secure auto HTTPS-only when available
name EIOU_WALLET_SESSION_<nodeHash> Custom session name. Suffixed with substr(sha256(public_key_pem), 0, 16) so two nodes sharing a hostname (only differ by port — typical localhost dev with :443 and :8443) write distinct cookies. Cookies ignore port per RFC 6265, so without the suffix the second login overwrites the first’s session and both tabs end up logged out. Falls back to EIOU_WALLET_SESSION_default while userconfig.json is missing (pre-init window).

Key Methods

Method Description
isAuthenticated() Check if user is logged in
authenticate($authCode, $userAuthCode) Validate auth code
checkSessionTimeout() Enforce configurable inactivity limit (default 30 min, reads from sessionTimeoutMinutes in config)
logout() Clear session and destroy cookie
requireAuth() Redirect to login if not authenticated
generateCSRFToken() Create secure token
validateCSRFToken($token) Verify token with constant-time comparison
verifyCSRFToken() Auto-verify POST requests
getCSRFField() Generate hidden input HTML
setMessage($message, $type) Set flash message
getMessage() Get and clear flash message

Session Security Features

Feature Implementation
Session regeneration Every 5 minutes
Auth code comparison hash_equals() constant-time
Session timeout Configurable (5/10/15/30/60 min, default 30)
ID regeneration on login Prevents session fixation
CSRF token expiration 1 hour max age

Security Features

CSRF Protection

All POST forms include a CSRF token:

<input type="hidden" name="csrf_token" value="<?php echo htmlspecialchars($csrfToken); ?>">

Token validation occurs at the start of every controller action via $this->session->verifyCSRFToken().


XSS Prevention

Location Method
View output htmlspecialchars($value, ENT_QUOTES, 'UTF-8')
URL parameters URL encoding + HTML escaping
JSON in HTML json_encode() + htmlspecialchars()
User input Security::sanitizeInput()

Input Validation

All user input is validated through InputValidator:

Validator Purpose
validateAddress($address) HTTP/HTTPS/Tor address format
validateContactName($name) Alphanumeric with spaces
validateFeePercent($fee) 0-100 range
validateCreditLimit($credit) Positive number
validateCurrency($currency) Supported currency code
validateAmount($amount, $currency) Positive transaction amount
validateNotSelfSend($address, $userContext) Prevent sending to self

Session Security

Protection Description
Constant-time comparison Auth code and CSRF validation
Session regeneration Periodic and on authentication
HTTP-only cookies Prevent XSS token theft
SameSite Strict Prevent CSRF via cookies
Inactivity timeout 30-minute auto-logout

Content Security Policy & Response Headers

Security::setGuiHeaders() runs at the top of the GUI entry point and sets, per request, a strict nonce-based Content-Security-Policy (both script-src and style-src are 'self' 'nonce-...', no 'unsafe-inline'), a Permissions-Policy (camera=(self) for the QR scanner, geolocation and microphone denied), and Cache-Control: no-store on the whole response. nginx supplies the static set (X-Frame-Options, X-Content-Type-Options, Referrer-Policy, HSTS). Full rationale in SECURITY.md → GUI Response Headers.

Because the policy forbids 'unsafe-inline', GUI markup (templates and JS-built HTML) must follow these rules, or the browser blocks it:

Don’t Do instead
Inline style="..." attributes A CSS class in page.css (reuse utilities like text-muted, d-none, u-fs1, w-100, or add one)
Inline onclick= / on*= handlers The delegated data-action / data-confirm dispatch, or addEventListener in script.js
Non-nonced inline <script> / <style> Add nonce="<?php echo cspNonce(); ?>" to the tag
Toggling visibility by writing the initial state into an inline style and resetting it with el.style.display = '' An initial d-none class plus el.classList.toggle('d-none', cond)

Styling set at runtime through the CSSOM (element.style.x = y) is not governed by style-src, so JS show/hide and dynamic positioning keep working. A plugin’s Plugins-tab panel renders into this same page and is subject to the same rules (see /docs/reference/plugins → The Plugins tab).


Helpers

MessageHelper

Utility class for message handling between controllers and views.

Key Methods

Method Description
parseContactOutput($output) Parse CLI output, determine message type
parseCliJsonOutput($output) Parse JSON CLI response
formatMessage($message, $type) Generate HTML message div
getMessageClass($type) Get CSS class for message type
getMessageIcon($type) Get icon character for message type
redirectMessage($message, $type, $url) Redirect with URL-encoded message
getMessageFromUrl() Extract message from GET params
displayFlashMessage($session) Render flash or URL message
getGuiFriendlyMessage($errorCode, $detail) Map error codes to user messages

Message Types

Type CSS Class Icon
success message-success checkmark
error message-error X
warning message-warning triangle
info message-info i
contact-accepted message-success checkmark

ViewHelper

Utility class for view rendering.

Method Description
sanitize($text) HTML escape with UTF-8
formatTimestamp($timestamp, $format) Format date/time
getTransactionClass($type) CSS class for transaction type
getStatusBadgeClass($status) CSS class for contact status
generatePagination($page, $total, $url) Render pagination links
renderSelectOptions($options, $selected) Generate option tags
generateBreadcrumbs($items) Render breadcrumb nav

ContactDataBuilder

Builds standardized contact data structures for the GUI.

Method Description
buildContactData($contact, $status) Create normalized contact array
buildEncodedContactData($contact, $status) JSON-encoded, HTML-safe for onclick

Contact Data Fields: name, address, fee, credit_limit, currency, status, pubkey, pubkey_hash, balance, balances_by_currency, contact_id, transactions, online_status, valid_chain, my_available_credit, their_available_credit, chain_drop_proposal, chain_gap_details, currencies, pending_currencies, outgoing_currencies, plus all dynamic address types.

Address Priority: Tor > HTTPS > HTTP (security preference)


Known Limitations

Limitation Description Workaround
No WebSockets Tor Browser blocks WebSockets Polling-based updates
Limited JavaScript Tor Browser security settings Server-side rendering
Session-based auth No persistent login Re-authenticate on browser close
No real-time updates Page refresh required Auto-refresh when enabled
Large contact lists Performance with >100 contacts Pagination/virtualization planned
Debug log size Full report slow over Tor Limited report option available

Development Setup

Volume Mount

During development, mount the source directory:

volumes:
  - ./files/src:/app/eiou/src:ro

File Paths

Context Path Prefix
Inside container /app/eiou/src/gui/
Outside container ./files/src/gui/
Browser assets /gui/assets/

Testing Changes

  1. Edit files in ./files/src/gui/
  2. Refresh browser (container uses mounted files)
  3. Check browser console for JS errors
  4. Check PHP logs for server errors

CSS Development

Styles are in assets/css/page.css, included inline via PHP require. Changes require browser refresh.

JavaScript Development

Scripts are in assets/js/script.js, included inline. Use browser dev tools for debugging.


See Also