super_ball/web/superball.html

1338 lines
44 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>⚡ Superball Node Setup</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0;
padding: 20px;
background: #ffffff;
min-height: 100vh;
color: #000000;
}
.section {
margin: 20px 0;
border: 1px solid #ddd;
padding: 15px;
border-radius: 5px;
}
.section h2 {
margin: 0 0 15px 0;
font-size: 18px;
}
input, textarea, button, select {
width: 100%;
padding: 8px;
margin: 5px 0;
border: 1px solid #ccc;
font-family: inherit;
border-radius: 3px;
}
button {
background: #f0f0f0;
cursor: pointer;
width: auto;
padding: 10px 20px;
}
button:hover {
background: #e0e0e0;
}
.button-primary {
background: #007bff;
color: white;
}
.button-primary:hover {
background: #0056b3;
}
.button-danger {
background: #dc3545;
color: white;
}
.button-danger:hover {
background: #c82333;
}
.input-group {
margin: 10px 0;
}
label {
display: block;
font-weight: bold;
margin-bottom: 3px;
}
#profile-picture {
width: 100px;
height: 100px;
border-radius: 50px;
margin-bottom: 10px;
}
.relay-item {
display: flex;
align-items: center;
gap: 10px;
margin: 5px 0;
padding: 8px;
border: 1px solid #eee;
border-radius: 3px;
background: #f9f9f9;
}
.relay-url {
flex: 1;
font-family: monospace;
font-size: 12px;
word-break: break-all;
}
.relay-type {
min-width: 80px;
font-size: 12px;
color: #666;
text-align: center;
}
.relay-actions {
display: flex;
gap: 5px;
}
.relay-actions button {
padding: 4px 8px;
font-size: 12px;
width: auto;
}
.hidden {
display: none;
}
.status-message {
padding: 10px;
margin: 10px 0;
border-radius: 3px;
}
.success {
background: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.error {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.info {
background: #d1ecf1;
color: #0c5460;
border: 1px solid #bee5eb;
}
.add-relay-form {
display: flex;
gap: 10px;
align-items: end;
}
.add-relay-form input {
flex: 1;
}
.add-relay-form select {
width: 100px;
}
.add-relay-form button {
width: auto;
margin: 5px 0;
}
.pubkey-display {
font-family: monospace;
font-size: 12px;
word-break: break-all;
background: #f8f9fa;
padding: 5px;
border-radius: 3px;
}
.action-buttons {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.daemon-control {
margin: 15px 0;
}
#daemon-toggle {
font-size: 16px;
padding: 12px 24px;
margin-bottom: 15px;
border-radius: 5px;
}
#daemon-toggle.running {
background: #dc3545;
}
#daemon-toggle.running:hover {
background: #c82333;
}
#daemon-status {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
background: #f8f9fa;
padding: 10px;
border-radius: 3px;
font-size: 14px;
}
.event-queue-item {
background: #fff3cd;
border: 1px solid #ffeaa7;
padding: 10px;
margin: 5px 0;
border-radius: 3px;
font-family: monospace;
font-size: 12px;
}
.event-queue-item.processing {
background: #d1ecf1;
border-color: #bee5eb;
}
.log-entry {
padding: 8px;
margin: 2px 0;
border-left: 3px solid #ddd;
background: #f8f9fa;
font-family: monospace;
font-size: 12px;
}
.log-entry.success {
border-left-color: #28a745;
background: #d4edda;
}
.log-entry.error {
border-left-color: #dc3545;
background: #f8d7da;
}
.log-entry.info {
border-left-color: #17a2b8;
background: #d1ecf1;
}
.log-timestamp {
color: #666;
font-weight: bold;
}
#processing-log {
max-height: 300px;
overflow-y: auto;
border: 1px solid #ddd;
border-radius: 3px;
}
</style>
</head>
<body>
<div id="login-section">
<!-- Login UI if needed -->
</div>
<div id="main-content" class="hidden">
<h1>⚡ Superball Node Setup</h1>
<!-- Profile Section -->
<div class="section">
<h2>🔐 Node Identity</h2>
<div id="profile-display">
<img id="profile-picture" alt="Profile Picture" style="display: none;">
<div><strong>Pubkey:</strong></div>
<div class="pubkey-display" id="profile-pubkey"></div>
<div><strong>Name:</strong> <span id="profile-name">Loading...</span></div>
<div><strong>About:</strong> <span id="profile-about">Loading...</span></div>
<div class="action-buttons">
<button onclick="toggleEditProfile()">Edit Profile</button>
</div>
</div>
<div id="profile-edit" class="hidden">
<div class="input-group">
<label for="edit-name">Name:</label>
<input type="text" id="edit-name" placeholder="Superball node name">
</div>
<div class="input-group">
<label for="edit-about">About:</label>
<textarea id="edit-about" rows="3" placeholder="Description of this Superball node"></textarea>
</div>
<div class="input-group">
<label for="edit-picture">Picture URL:</label>
<input type="url" id="edit-picture" placeholder="https://example.com/profile.jpg">
</div>
<div class="action-buttons">
<button class="button-primary" onclick="saveProfile()">Save Profile</button>
<button onclick="cancelEditProfile()">Cancel</button>
</div>
</div>
<div id="profile-status"></div>
</div>
<!-- Relay Management Section -->
<div class="section">
<h2>📡 Relay Configuration</h2>
<p>Configure which relays this Superball node will monitor for routing events. Follow NIP-65 standards.</p>
<div class="input-group">
<label>Add New Relay:</label>
<div class="add-relay-form">
<input type="url" id="new-relay-url" placeholder="wss://relay.example.com">
<select id="new-relay-type">
<option value="">Both</option>
<option value="read">Read</option>
<option value="write">Write</option>
</select>
<button class="button-primary" onclick="addRelay()">Add</button>
</div>
</div>
<div id="relay-list">
<!-- Relay items will be populated here -->
</div>
<div class="action-buttons">
<button class="button-primary" onclick="saveRelayList()">Save Relay Configuration</button>
<button onclick="loadRelayList()">Reload from Network</button>
</div>
<div id="relay-status"></div>
</div>
<!-- Superball Daemon Control Section -->
<div class="section">
<h2>⚡ Superball Daemon</h2>
<div class="daemon-control">
<button id="daemon-toggle" class="button-primary" onclick="toggleDaemon()">
<span id="daemon-button-text">Start Daemon</span>
</button>
<div id="daemon-status">
<div><strong>Status:</strong> <span id="daemon-status-text">Stopped</span></div>
<div><strong>Monitoring Relays:</strong> <span id="monitoring-relays">0</span></div>
<div><strong>Events Processed:</strong> <span id="events-processed">0</span></div>
<div><strong>Events in Queue:</strong> <span id="events-queued">0</span></div>
</div>
</div>
</div>
<!-- Event Queue Section -->
<div class="section">
<h2>📋 Event Queue</h2>
<div id="event-queue">
<div class="info status-message">No events in queue</div>
</div>
</div>
<!-- Processing Log Section -->
<div class="section">
<h2>📝 Processing Log</h2>
<div id="processing-log">
<div class="info status-message">Daemon stopped - no activity</div>
</div>
</div>
</div>
<!-- Load the official nostr-tools bundle first -->
<script src="./nostr.bundle.js"></script>
<!-- Load NOSTR_LOGIN_LITE main library -->
<script src="./nostr-lite.js"></script>
<script>
// Global variables
let nlLite = null;
let userPubkey = null;
let relayUrl = 'wss://relay.laantungir.net';
let currentProfile = {};
let currentRelays = [];
// Daemon variables
let daemonRunning = false;
let monitoringWebSockets = [];
let eventQueue = [];
let processedEvents = 0;
let logEntries = [];
let subscriptionId = null;
// Initialize NOSTR_LOGIN_LITE
async function initializeApp() {
try {
await window.NOSTR_LOGIN_LITE.init({
theme: 'default',
darkMode: false,
methods: {
extension: true,
local: true,
seedphrase: true,
connect: true,
remote: true,
otp: true
},
floatingTab: {
enabled: true,
hPosition: .98,
vPosition: 0,
getUserInfo: true,
getUserRelay: ['wss://relay.laantungir.net'],
appearance: {
style: 'minimal',
theme: 'auto',
icon: '',
text: 'Login',
iconOnly: false
},
behavior: {
hideWhenAuthenticated: false,
showUserInfo: true,
autoSlide: false,
persistent: false
},
animation: {
slideDirection: 'right'
}
},
debug: true
});
nlLite = window.NOSTR_LOGIN_LITE;
console.log('SUCCESS', 'NOSTR_LOGIN_LITE initialized successfully');
window.addEventListener('nlMethodSelected', handleAuthEvent);
} catch (error) {
console.log('ERROR', `Initialization failed: ${error.message}`);
}
}
function handleAuthEvent(event) {
const { pubkey, method, error } = event.detail;
console.log('INFO', `Auth event received: method=${method}`);
if (method && pubkey) {
userPubkey = pubkey;
console.log('SUCCESS', `Login successful! Method: ${method}, Pubkey: ${pubkey}`);
document.getElementById('main-content').classList.remove('hidden');
document.getElementById('profile-pubkey').textContent = pubkey;
loadUserProfile();
loadRelayList();
} else if (error) {
console.log('ERROR', `Authentication error: ${error}`);
}
}
// Load user profile
async function loadUserProfile() {
if (!userPubkey) return;
console.log('INFO', `Loading profile for: ${userPubkey}`);
document.getElementById('profile-name').textContent = 'Loading profile...';
try {
const pool = new window.NostrTools.SimplePool();
const relays = [relayUrl, 'wss://relay.laantungir.net'];
const events = await pool.querySync(relays, {
kinds: [0],
authors: [userPubkey],
limit: 1
});
pool.close(relays);
if (events.length > 0) {
console.log('SUCCESS', 'Profile event received');
currentProfile = JSON.parse(events[0].content);
displayProfile(currentProfile);
} else {
console.log('INFO', 'No profile found');
currentProfile = {};
displayProfile(currentProfile);
}
} catch (error) {
console.log('ERROR', `Profile loading failed: ${error.message}`);
showStatus('profile-status', 'Error loading profile: ' + error.message, 'error');
}
}
// Display profile data
function displayProfile(profile) {
const name = profile.name || profile.display_name || profile.displayName || 'Unnamed Superball Node';
const about = profile.about || 'No description provided';
const picture = profile.picture || '';
document.getElementById('profile-name').textContent = name;
document.getElementById('profile-about').textContent = about;
if (picture) {
document.getElementById('profile-picture').src = picture;
document.getElementById('profile-picture').style.display = 'block';
}
console.log('SUCCESS', `Profile displayed: ${name}`);
}
// Toggle profile editing
function toggleEditProfile() {
const display = document.getElementById('profile-display');
const edit = document.getElementById('profile-edit');
display.classList.add('hidden');
edit.classList.remove('hidden');
// Populate edit fields
document.getElementById('edit-name').value = currentProfile.name || '';
document.getElementById('edit-about').value = currentProfile.about || '';
document.getElementById('edit-picture').value = currentProfile.picture || '';
}
function cancelEditProfile() {
document.getElementById('profile-display').classList.remove('hidden');
document.getElementById('profile-edit').classList.add('hidden');
}
// Save profile
async function saveProfile() {
if (!userPubkey) return;
const name = document.getElementById('edit-name').value.trim();
const about = document.getElementById('edit-about').value.trim();
const picture = document.getElementById('edit-picture').value.trim();
try {
const profileData = {
name: name || 'Unnamed Superball Node',
about: about || 'Superball anonymity node',
};
if (picture) {
profileData.picture = picture;
}
const eventTemplate = {
kind: 0,
content: JSON.stringify(profileData),
tags: [],
created_at: Math.floor(Date.now() / 1000)
};
console.log('DEBUG: Event template to sign:', JSON.stringify(eventTemplate, null, 2));
const signedEvent = await window.nostr.signEvent(eventTemplate);
console.log('DEBUG: Signed event:', JSON.stringify(signedEvent, null, 2));
// Publish to relays using Promise.any as per nostr-tools example
const relaysToUse = [...new Set([relayUrl, 'wss://relay.laantungir.net'])]; // Remove duplicates
console.log('DEBUG: Publishing to relays:', relaysToUse);
const pool = new window.NostrTools.SimplePool();
try {
// Use Promise.any() to publish to all relays - succeeds if ANY relay accepts
console.log('DEBUG: Using Promise.any() to publish to all relays simultaneously');
await Promise.any(pool.publish(relaysToUse, signedEvent));
pool.close(relaysToUse);
currentProfile = profileData;
displayProfile(currentProfile);
cancelEditProfile();
showStatus('profile-status', 'Profile saved successfully!', 'success');
console.log('SUCCESS', 'Profile published to at least one relay');
} catch (aggregateError) {
pool.close(relaysToUse);
console.log('ERROR: All relays failed:', aggregateError.errors);
// Extract individual error messages
const errorMessages = aggregateError.errors.map((err, index) =>
`${relaysToUse[index]}: ${err.message}`
).join(', ');
throw new Error('Failed to publish to any relay: ' + errorMessages);
}
} catch (error) {
console.log('ERROR', `Failed to save profile: ${error.message}`);
showStatus('profile-status', 'Failed to save profile: ' + error.message, 'error');
}
}
// Load relay list (NIP-65)
async function loadRelayList() {
if (!userPubkey) return;
console.log('INFO', `Loading relay list for: ${userPubkey}`);
try {
const pool = new window.NostrTools.SimplePool();
const relays = [relayUrl, 'wss://relay.laantungir.net'];
const events = await pool.querySync(relays, {
kinds: [10002],
authors: [userPubkey],
limit: 1
});
pool.close(relays);
currentRelays = [];
if (events.length > 0) {
console.log('SUCCESS', 'Relay list event received');
const relayTags = events[0].tags.filter(tag => tag[0] === 'r');
currentRelays = relayTags.map(tag => ({
url: tag[1],
type: tag[2] || ''
}));
} else {
console.log('INFO', 'No relay list found, using defaults');
currentRelays = [
{ url: 'wss://relay.laantungir.net', type: '' }
];
}
displayRelayList();
} catch (error) {
console.log('ERROR', `Relay list loading failed: ${error.message}`);
showStatus('relay-status', 'Error loading relay list: ' + error.message, 'error');
}
}
// Display relay list
function displayRelayList() {
const container = document.getElementById('relay-list');
container.innerHTML = '';
if (currentRelays.length === 0) {
container.innerHTML = '<div class="info status-message">No relays configured. Add relays above.</div>';
return;
}
currentRelays.forEach((relay, index) => {
const relayItem = document.createElement('div');
relayItem.className = 'relay-item';
relayItem.innerHTML = `
<div class="relay-url">${relay.url}</div>
<div class="relay-type">${relay.type || 'both'}</div>
<div class="relay-actions">
<button onclick="removeRelay(${index})">Remove</button>
</div>
`;
container.appendChild(relayItem);
});
}
// Add new relay
function addRelay() {
const url = document.getElementById('new-relay-url').value.trim();
const type = document.getElementById('new-relay-type').value;
if (!url) {
showStatus('relay-status', 'Please enter a relay URL', 'error');
return;
}
if (!url.startsWith('wss://') && !url.startsWith('ws://')) {
showStatus('relay-status', 'Relay URL must start with wss:// or ws://', 'error');
return;
}
// Check for duplicates
if (currentRelays.some(r => r.url === url)) {
showStatus('relay-status', 'Relay already exists', 'error');
return;
}
currentRelays.push({ url, type });
displayRelayList();
// Clear form
document.getElementById('new-relay-url').value = '';
document.getElementById('new-relay-type').value = '';
showStatus('relay-status', 'Relay added (remember to save)', 'info');
}
// Remove relay
function removeRelay(index) {
currentRelays.splice(index, 1);
displayRelayList();
showStatus('relay-status', 'Relay removed (remember to save)', 'info');
}
// Save relay list (NIP-65)
async function saveRelayList() {
if (!userPubkey) return;
try {
const tags = currentRelays.map(relay => {
const tag = ['r', relay.url];
if (relay.type) {
tag.push(relay.type);
}
return tag;
});
const eventTemplate = {
kind: 10002,
content: '',
tags: tags,
created_at: Math.floor(Date.now() / 1000)
};
console.log('DEBUG: Relay event template to sign:', JSON.stringify(eventTemplate, null, 2));
const signedEvent = await window.nostr.signEvent(eventTemplate);
console.log('DEBUG: Signed relay event:', JSON.stringify(signedEvent, null, 2));
// Publish to relays using Promise.any as per nostr-tools example
const relaysToUse = [...new Set([relayUrl, 'wss://relay.laantungir.net'])]; // Remove duplicates
console.log('DEBUG: Publishing relay list to relays:', relaysToUse);
const pool = new window.NostrTools.SimplePool();
try {
// Use Promise.any() to publish to all relays - succeeds if ANY relay accepts
console.log('DEBUG: Using Promise.any() to publish relay list to all relays simultaneously');
await Promise.any(pool.publish(relaysToUse, signedEvent));
pool.close(relaysToUse);
showStatus('relay-status', 'Relay configuration saved successfully!', 'success');
console.log('SUCCESS', 'Relay list published to at least one relay');
} catch (aggregateError) {
pool.close(relaysToUse);
console.log('ERROR: All relay list publish attempts failed:', aggregateError.errors);
// Extract individual error messages
const errorMessages = aggregateError.errors.map((err, index) =>
`${relaysToUse[index]}: ${err.message}`
).join(', ');
throw new Error('Failed to publish relay list to any relay: ' + errorMessages);
}
} catch (error) {
console.log('ERROR', `Failed to save relay list: ${error.message}`);
showStatus('relay-status', 'Failed to save relay list: ' + error.message, 'error');
}
}
// Show status message
function showStatus(containerId, message, type) {
const container = document.getElementById(containerId);
container.innerHTML = `<div class="status-message ${type}">${message}</div>`;
// Auto-clear after 5 seconds for non-error messages
if (type !== 'error') {
setTimeout(() => {
container.innerHTML = '';
}, 5000);
}
}
// ============ SUPERBALL DAEMON FUNCTIONALITY ============
// Toggle daemon on/off
function toggleDaemon() {
if (daemonRunning) {
stopDaemon();
} else {
startDaemon();
}
}
// Start the Superball daemon
async function startDaemon() {
if (!userPubkey || currentRelays.length === 0) {
addLogEntry('error', 'Cannot start daemon: Profile not loaded or no relays configured');
showStatus('relay-status', 'Please configure profile and relays before starting daemon', 'error');
return;
}
try {
addLogEntry('info', 'Starting Superball daemon...');
daemonRunning = true;
updateDaemonUI();
// Get relay URLs for monitoring
const monitoringRelays = currentRelays.map(r => r.url);
addLogEntry('info', `Connecting to ${monitoringRelays.length} relays via WebSocket for ALL kind 22222 events`);
// Generate unique subscription ID
subscriptionId = 'superball_' + Date.now();
// Subscribe to kind 22222 events with p tag matching this node's pubkey and created_at > now
const now = Math.floor(Date.now() / 1000);
const subscriptionFilter = {
kinds: [22222],
'#p': [userPubkey],
since: now
};
addLogEntry('info', `Subscription ID: ${subscriptionId}`);
addLogEntry('info', `Subscription filter: ${JSON.stringify(subscriptionFilter)}`);
// Connect to each relay with direct WebSocket
monitoringRelays.forEach((relayUrl, index) => {
connectToRelay(relayUrl, subscriptionFilter);
});
// Update monitoring count
document.getElementById('monitoring-relays').textContent = monitoringRelays.length;
} catch (error) {
addLogEntry('error', `Failed to start daemon: ${error.message}`);
daemonRunning = false;
updateDaemonUI();
}
}
// Stop the Superball daemon
function stopDaemon() {
addLogEntry('info', 'Stopping Superball daemon...');
daemonRunning = false;
// Close all WebSocket connections
monitoringWebSockets.forEach((ws, index) => {
if (ws.readyState === WebSocket.OPEN) {
// Send CLOSE message for subscription
const closeMsg = JSON.stringify(['CLOSE', subscriptionId]);
ws.send(closeMsg);
addLogEntry('info', `Sent CLOSE to relay ${index + 1}`);
// Close WebSocket
ws.close();
}
});
monitoringWebSockets = [];
subscriptionId = null;
// Clear event queue
eventQueue = [];
updateDaemonUI();
updateEventQueue();
addLogEntry('success', 'Daemon stopped successfully');
}
// Connect to relay via direct WebSocket
function connectToRelay(relayUrl, subscriptionFilter) {
addLogEntry('info', `Connecting to relay: ${relayUrl}`);
const ws = new WebSocket(relayUrl);
ws.onopen = () => {
addLogEntry('success', `Connected to relay: ${relayUrl}`);
// Send subscription request
const reqMessage = JSON.stringify([
'REQ',
subscriptionId,
subscriptionFilter
]);
addLogEntry('info', `Sending REQ to ${relayUrl}: ${reqMessage}`);
ws.send(reqMessage);
};
ws.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
addLogEntry('info', `Received from ${relayUrl}: ${JSON.stringify(message).substring(0, 200)}...`);
// Handle different message types
if (message[0] === 'EVENT' && message[1] === subscriptionId) {
const nostrEvent = message[2];
addLogEntry('success', `Received EVENT from ${relayUrl}: ${nostrEvent.id.substring(0, 16)}...`);
// Truncate content for readability
const truncatedEvent = { ...nostrEvent };
if (truncatedEvent.content && truncatedEvent.content.length > 10) {
const content = truncatedEvent.content;
truncatedEvent.content = content.substring(0, 5) + '...' + content.substring(content.length - 5);
}
addLogEntry('info', `Full received event:\n${JSON.stringify(truncatedEvent, null, 2)}`);
handleIncomingEvent(nostrEvent);
} else if (message[0] === 'EOSE' && message[1] === subscriptionId) {
addLogEntry('info', `End of stored events from ${relayUrl}`);
} else if (message[0] === 'NOTICE') {
addLogEntry('info', `Notice from ${relayUrl}: ${message[1]}`);
} else if (message[0] === 'OK') {
addLogEntry('info', `OK response from ${relayUrl}: ${JSON.stringify(message)}`);
}
} catch (error) {
addLogEntry('error', `Error parsing message from ${relayUrl}: ${error.message}`);
}
};
ws.onerror = (error) => {
addLogEntry('error', `WebSocket error with ${relayUrl}: ${error}`);
};
ws.onclose = (event) => {
addLogEntry('info', `Connection closed to ${relayUrl} - Code: ${event.code}, Reason: ${event.reason}`);
};
monitoringWebSockets.push(ws);
}
// Handle incoming routing events
async function handleIncomingEvent(event) {
addLogEntry('info', `Received routing event: ${event.id.substring(0,16)}...`);
try {
// Decrypt the event payload
let decryptedPayload = await decryptRoutingEvent(event);
if (!decryptedPayload) {
addLogEntry('error', `Failed to decrypt event ${event.id.substring(0,16)}...`);
return;
}
addLogEntry('success', `First decryption successful for event ${event.id.substring(0,16)}...`);
// Check payload type according to corrected DAEMON.md protocol
if (decryptedPayload.padding !== undefined) {
addLogEntry('info', `Detected Type 2 (Padding Payload) - discarding padding and performing second decryption`);
// This is a padding layer from previous daemon - discard padding and decrypt again
const innerEvent = decryptedPayload.event;
addLogEntry('info', `Discarding padding: "${decryptedPayload.padding}"`);
// Second decryption to get the actual routing instructions that were encrypted for me
decryptedPayload = await decryptRoutingEvent(innerEvent);
if (!decryptedPayload) {
addLogEntry('error', `Failed to decrypt inner event ${event.id.substring(0,16)}...`);
return;
}
addLogEntry('success', `Second decryption successful - found my original routing instructions from builder`);
} else {
addLogEntry('info', `Detected Type 1 (Routing Payload) - processing routing instructions directly`);
}
// Log the complete decrypted payload with truncated content
const truncatedPayload = { ...decryptedPayload };
if (truncatedPayload.event && truncatedPayload.event.content && truncatedPayload.event.content.length > 10) {
const content = truncatedPayload.event.content;
truncatedPayload.event.content = content.substring(0, 5) + '...' + content.substring(content.length - 5);
}
addLogEntry('info', `Final routing payload:\n${JSON.stringify(truncatedPayload, null, 2)}`);
// Parse routing instructions (these are from the builder, specific to this daemon)
const { event: wrappedEvent, routing } = decryptedPayload;
if (!routing) {
addLogEntry('error', `No routing instructions found in final payload for ${event.id.substring(0,16)}...`);
return;
}
// DEBUG: Log routing decision
addLogEntry('info', `DEBUG routing.p = "${routing.p}" (type: ${typeof routing.p})`);
if (routing.add_padding_bytes) {
addLogEntry('info', `DEBUG add_padding_bytes = ${routing.add_padding_bytes}`);
}
addLogEntry('info', `DEBUG Will ${routing.p ? 'FORWARD to next hop' : 'POST FINAL EVENT'}`);
if (!validateRoutingInstructions(routing)) {
addLogEntry('error', `Invalid routing instructions in event ${event.id.substring(0,16)}...`);
return;
}
// Create queue item
const queueItem = {
id: event.id,
wrappedEvent,
routing,
receivedAt: Date.now(),
processAt: Date.now() + (routing.delay * 1000),
status: 'queued'
};
eventQueue.push(queueItem);
updateEventQueue();
addLogEntry('info', `Event queued for processing in ${routing.delay}s: ${event.id.substring(0,16)}...`);
// Schedule processing
setTimeout(() => processQueuedEvent(queueItem), routing.delay * 1000);
} catch (error) {
addLogEntry('error', `Error processing event ${event.id.substring(0,16)}...: ${error.message}`);
}
}
// Decrypt routing event using NIP-44 via NIP-07 interface
async function decryptRoutingEvent(event) {
try {
// Use window.nostr.nip44.decrypt() which handles the private key internally
const decrypted = await window.nostr.nip44.decrypt(event.pubkey, event.content);
return JSON.parse(decrypted);
} catch (error) {
console.error('Decryption error:', error);
return null;
}
}
// Validate routing instructions
function validateRoutingInstructions(routing) {
if (!routing || typeof routing !== 'object') return false;
if (!Array.isArray(routing.relays) || routing.relays.length === 0) return false;
if (typeof routing.delay !== 'number' || routing.delay < 0) return false;
if (!routing.audit || typeof routing.audit !== 'string') return false;
return true;
}
// Process a queued event according to corrected DAEMON.md protocol
async function processQueuedEvent(queueItem) {
if (!daemonRunning) return;
addLogEntry('info', `Processing event ${queueItem.id.substring(0,16)}...`);
queueItem.status = 'processing';
updateEventQueue();
try {
const { wrappedEvent, routing } = queueItem;
// Check if this is final posting or continued routing
addLogEntry('info', `DEBUG Decision point - routing.p = "${routing.p}" (${typeof routing.p})`);
if (routing.add_padding_bytes) {
addLogEntry('info', `DEBUG add_padding_bytes = ${routing.add_padding_bytes} (will add padding when forwarding)`);
}
addLogEntry('info', `DEBUG Will ${routing.p ? 'FORWARD with padding wrapper' : 'POST FINAL EVENT'}`);
if (routing.p) {
// Continue routing to next Superball with padding-only wrapper
await forwardToNextSuperball(wrappedEvent, routing);
} else {
// Final posting - post the wrapped event directly
await postFinalEvent(wrappedEvent, routing.relays);
}
processedEvents++;
document.getElementById('events-processed').textContent = processedEvents;
// Remove from queue
const index = eventQueue.findIndex(item => item.id === queueItem.id);
if (index !== -1) {
eventQueue.splice(index, 1);
updateEventQueue();
}
addLogEntry('success', `Successfully processed event ${queueItem.id.substring(0,16)}...`);
} catch (error) {
addLogEntry('error', `Failed to process event ${queueItem.id.substring(0,16)}...: ${error.message}`);
// Mark as failed
queueItem.status = 'failed';
updateEventQueue();
}
}
// Legacy padding function - no longer used in corrected protocol
// Padding is now handled during forwarding with add_padding_bytes
function applyPadding(event, paddingInstruction) {
// This function is deprecated in the corrected protocol
// Padding is now generated during forwarding based on routing.add_padding_bytes
addLogEntry('info', 'Legacy padding function called - no action taken (using new protocol)');
return event;
}
// Forward event to next Superball with padding-only wrapper (DAEMON.md corrected protocol)
async function forwardToNextSuperball(event, routing) {
addLogEntry('info', `Forwarding to next Superball: ${routing.p.substring(0,16)}...`);
// Create new ephemeral keypair
const ephemeralKey = window.NostrTools.generateSecretKey();
const ephemeralPubkey = window.NostrTools.getPublicKey(ephemeralKey);
// Generate padding based on add_padding_bytes instruction
let paddingData = '';
if (routing.add_padding_bytes && routing.add_padding_bytes > 0) {
// Create random padding of specified length
const chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
for (let i = 0; i < routing.add_padding_bytes; i++) {
paddingData += chars.charAt(Math.floor(Math.random() * chars.length));
}
addLogEntry('info', `Generated ${paddingData.length} bytes of padding`);
}
// Create padding-only payload (NEVER create routing instructions - only builder does that)
const paddingPayload = {
event: event, // This is the still-encrypted inner event
padding: paddingData // Padding to discard
};
addLogEntry('info', `DEBUG Creating padding payload with ${paddingData.length} bytes of padding`);
// Log the complete padding payload with truncated content before encryption
const truncatedPaddingPayload = { ...paddingPayload };
if (truncatedPaddingPayload.event && truncatedPaddingPayload.event.content && truncatedPaddingPayload.event.content.length > 10) {
const content = truncatedPaddingPayload.event.content;
truncatedPaddingPayload.event.content = content.substring(0, 5) + '...' + content.substring(content.length - 5);
}
if (truncatedPaddingPayload.padding && truncatedPaddingPayload.padding.length > 10) {
const padding = truncatedPaddingPayload.padding;
truncatedPaddingPayload.padding = padding.substring(0, 5) + '...' + padding.substring(padding.length - 5);
}
addLogEntry('info', `Padding payload to encrypt:\n${JSON.stringify(truncatedPaddingPayload, null, 2)}`);
// Encrypt padding payload to next Superball
let ephemeralKeyHex;
if (window.NostrTools.utils && window.NostrTools.utils.bytesToHex) {
ephemeralKeyHex = window.NostrTools.utils.bytesToHex(ephemeralKey);
} else if (window.NostrTools.bytesToHex) {
ephemeralKeyHex = window.NostrTools.bytesToHex(ephemeralKey);
} else {
// Fallback: convert Uint8Array to hex manually
ephemeralKeyHex = Array.from(ephemeralKey).map(b => b.toString(16).padStart(2, '0')).join('');
}
const conversationKey = window.NostrTools.nip44.v2.utils.getConversationKey(
ephemeralKeyHex,
routing.p
);
const encryptedContent = window.NostrTools.nip44.v2.encrypt(
JSON.stringify(paddingPayload),
conversationKey
);
// Create new routing event (forwarding to next hop)
const routingEvent = {
kind: 22222,
content: encryptedContent,
tags: [
['p', routing.p], // Next Superball
['p', routing.audit] // Audit tag (camouflage)
],
created_at: Math.floor(Date.now() / 1000)
};
// Sign with ephemeral key
const signedEvent = window.NostrTools.finalizeEvent(routingEvent, ephemeralKey);
// Log the complete rewrapped event with truncated content before publishing
const truncatedSignedEvent = { ...signedEvent };
if (truncatedSignedEvent.content && truncatedSignedEvent.content.length > 10) {
const content = truncatedSignedEvent.content;
truncatedSignedEvent.content = content.substring(0, 5) + '...' + content.substring(content.length - 5);
}
addLogEntry('info', `Padding-wrapped event to publish:\n${JSON.stringify(truncatedSignedEvent, null, 2)}`);
// Publish to specified relays
await publishToRelays(signedEvent, routing.relays);
addLogEntry('success', `Forwarded with padding layer to ${routing.p.substring(0,16)}... (audit: ${routing.audit.substring(0,16)}...)`);
}
// Post final event directly to relays
async function postFinalEvent(event, relays) {
addLogEntry('info', `Posting final event to ${relays.length} relays`);
await publishToRelays(event, relays);
addLogEntry('success', `Final event posted to relays: ${event.id.substring(0,16)}...`);
}
// Publish event to relays
async function publishToRelays(event, relays) {
const pool = new window.NostrTools.SimplePool();
try {
await Promise.any(pool.publish(relays, event));
addLogEntry('success', `Published to relays: ${relays.join(', ')}`);
// Truncate content for readability
const truncatedEvent = { ...event };
if (truncatedEvent.content && truncatedEvent.content.length > 10) {
const content = truncatedEvent.content;
truncatedEvent.content = content.substring(0, 5) + '...' + content.substring(content.length - 5);
}
addLogEntry('info', `Full published event:\n${JSON.stringify(truncatedEvent, null, 2)}`);
} catch (aggregateError) {
const errorMessages = aggregateError.errors.map((err, index) =>
`${relays[index]}: ${err.message}`
).join(', ');
throw new Error('Failed to publish to any relay: ' + errorMessages);
} finally {
pool.close(relays);
}
}
// Update daemon UI
function updateDaemonUI() {
const button = document.getElementById('daemon-toggle');
const buttonText = document.getElementById('daemon-button-text');
const statusText = document.getElementById('daemon-status-text');
if (daemonRunning) {
button.classList.add('running');
buttonText.textContent = 'Stop Daemon';
statusText.textContent = 'Running';
statusText.style.color = '#28a745';
} else {
button.classList.remove('running');
buttonText.textContent = 'Start Daemon';
statusText.textContent = 'Stopped';
statusText.style.color = '#dc3545';
// Reset counters when stopped
document.getElementById('monitoring-relays').textContent = '0';
document.getElementById('events-queued').textContent = '0';
}
}
// Update event queue display
function updateEventQueue() {
const container = document.getElementById('event-queue');
if (eventQueue.length === 0) {
container.innerHTML = '<div class="info status-message">No events in queue</div>';
document.getElementById('events-queued').textContent = '0';
return;
}
container.innerHTML = '';
document.getElementById('events-queued').textContent = eventQueue.length;
eventQueue.forEach(item => {
const div = document.createElement('div');
div.className = `event-queue-item ${item.status}`;
const timeLeft = Math.max(0, Math.ceil((item.processAt - Date.now()) / 1000));
const statusText = item.status === 'queued' ?
`Queued - Processing in ${timeLeft}s` :
item.status.charAt(0).toUpperCase() + item.status.slice(1);
div.innerHTML = `
<div><strong>Event:</strong> ${item.id.substring(0,32)}...</div>
<div><strong>Status:</strong> ${statusText}</div>
<div><strong>Target Relays:</strong> ${item.routing.relays.length}</div>
<div><strong>Delay:</strong> ${item.routing.delay}s</div>
${item.routing.padding ? `<div><strong>Padding:</strong> ${item.routing.padding}</div>` : ''}
${item.routing.p ? `<div><strong>Next Hop:</strong> ${item.routing.p.substring(0,16)}...</div>` : '<div><strong>Final Posting</strong></div>'}
`;
container.appendChild(div);
});
}
// Add log entry
function addLogEntry(type, message) {
const timestamp = new Date().toLocaleTimeString();
const entry = {
timestamp,
type,
message
};
logEntries.push(entry);
// Keep only last 100 entries
if (logEntries.length > 100) {
logEntries.shift();
}
updateProcessingLog();
}
// Update processing log display
function updateProcessingLog() {
const container = document.getElementById('processing-log');
if (logEntries.length === 0) {
container.innerHTML = '<div class="info status-message">Daemon stopped - no activity</div>';
return;
}
container.innerHTML = '';
// Show most recent entries first
logEntries.slice().reverse().forEach(entry => {
const div = document.createElement('div');
div.className = `log-entry ${entry.type}`;
// Handle multiline messages (like JSON) with proper formatting
const messageContent = entry.message.includes('\n') ?
`<span class="log-timestamp">${entry.timestamp}</span><br><pre style="margin: 5px 0; font-family: monospace; font-size: 11px; white-space: pre-wrap;">${entry.message}</pre>` :
`<span class="log-timestamp">${entry.timestamp}</span> ${entry.message}`;
div.innerHTML = messageContent;
container.appendChild(div);
});
// Auto-scroll to top (most recent)
container.scrollTop = 0;
}
// Update queue timers every second
setInterval(() => {
if (daemonRunning && eventQueue.length > 0) {
updateEventQueue();
}
}, 1000);
document.addEventListener('DOMContentLoaded', () => {
setTimeout(initializeApp, 100);
});
</script>
</body>
</html>