1276 lines
38 KiB
HTML
1276 lines
38 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)}...`);
|
|
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
|
|
const decryptedPayload = await decryptRoutingEvent(event);
|
|
|
|
if (!decryptedPayload) {
|
|
addLogEntry('error', `Failed to decrypt event ${event.id.substring(0,16)}...`);
|
|
return;
|
|
}
|
|
|
|
addLogEntry('success', `Successfully decrypted routing event ${event.id.substring(0,16)}...`);
|
|
|
|
// Parse routing instructions
|
|
const { event: wrappedEvent, routing } = decryptedPayload;
|
|
|
|
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
|
|
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;
|
|
|
|
// Apply padding if specified
|
|
let eventToForward = { ...wrappedEvent };
|
|
if (routing.padding) {
|
|
eventToForward = applyPadding(eventToForward, routing.padding);
|
|
}
|
|
|
|
// Check if this is final posting or continued routing
|
|
if (routing.p) {
|
|
// Continue routing to next Superball
|
|
await forwardToNextSuperball(eventToForward, routing);
|
|
} else {
|
|
// Final posting - post original event directly
|
|
await postFinalEvent(eventToForward, 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();
|
|
}
|
|
}
|
|
|
|
// Apply padding to event
|
|
function applyPadding(event, paddingInstruction) {
|
|
if (!paddingInstruction || typeof paddingInstruction !== 'string') return event;
|
|
|
|
const match = paddingInstruction.match(/^([+-])(\d+)$/);
|
|
if (!match) return event;
|
|
|
|
const [, operation, amount] = match;
|
|
const padding = '1'.repeat(parseInt(amount));
|
|
|
|
const modifiedEvent = { ...event };
|
|
|
|
if (operation === '+') {
|
|
// Add padding
|
|
if (!modifiedEvent.tags.find(tag => tag[0] === 'padding')) {
|
|
modifiedEvent.tags.push(['padding', padding]);
|
|
}
|
|
addLogEntry('info', `Added ${amount} bytes of padding`);
|
|
} else if (operation === '-') {
|
|
// Remove padding
|
|
const paddingTagIndex = modifiedEvent.tags.findIndex(tag => tag[0] === 'padding');
|
|
if (paddingTagIndex !== -1) {
|
|
const currentPadding = modifiedEvent.tags[paddingTagIndex][1] || '';
|
|
const newPadding = currentPadding.substring(parseInt(amount));
|
|
if (newPadding.length > 0) {
|
|
modifiedEvent.tags[paddingTagIndex][1] = newPadding;
|
|
} else {
|
|
modifiedEvent.tags.splice(paddingTagIndex, 1);
|
|
}
|
|
}
|
|
addLogEntry('info', `Removed ${amount} bytes of padding`);
|
|
}
|
|
|
|
return modifiedEvent;
|
|
}
|
|
|
|
// Forward event to next Superball (Always Rewrap)
|
|
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);
|
|
|
|
// Create new encrypted payload
|
|
const payload = {
|
|
event: event,
|
|
routing: {
|
|
relays: routing.relays,
|
|
delay: routing.delay,
|
|
padding: routing.padding,
|
|
p: routing.p,
|
|
audit: routing.audit,
|
|
payment: routing.payment
|
|
}
|
|
};
|
|
|
|
// Encrypt to next Superball
|
|
const conversationKey = window.NostrTools.nip44.v2.utils.getConversationKey(
|
|
window.NostrTools.bytesToHex(ephemeralKey),
|
|
routing.p
|
|
);
|
|
|
|
const encryptedContent = window.NostrTools.nip44.v2.encrypt(
|
|
JSON.stringify(payload),
|
|
conversationKey
|
|
);
|
|
|
|
// Create new routing event
|
|
const routingEvent = {
|
|
kind: 22222,
|
|
content: encryptedContent,
|
|
tags: [
|
|
['p', routing.p], // Next Superball
|
|
['p', routing.audit] // Audit tag (looks like pubkey)
|
|
],
|
|
created_at: Math.floor(Date.now() / 1000)
|
|
};
|
|
|
|
// Add padding tag if present
|
|
const paddingTag = event.tags.find(tag => tag[0] === 'padding');
|
|
if (paddingTag) {
|
|
routingEvent.tags.push(['padding', paddingTag[1]]);
|
|
}
|
|
|
|
// Sign with ephemeral key
|
|
const signedEvent = window.NostrTools.finalizeEvent(routingEvent, ephemeralKey);
|
|
|
|
// Publish to specified relays
|
|
await publishToRelays(signedEvent, routing.relays);
|
|
|
|
addLogEntry('success', `Forwarded event with audit tag ${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(', ')}`);
|
|
} 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}`;
|
|
div.innerHTML = `
|
|
<span class="log-timestamp">${entry.timestamp}</span> ${entry.message}
|
|
`;
|
|
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>
|