1086 lines
38 KiB
HTML
1086 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 Event Builder</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;
|
||
}
|
||
|
||
.section h2 {
|
||
margin: 0 0 15px 0;
|
||
font-size: 18px;
|
||
}
|
||
|
||
input, textarea, button {
|
||
width: 100%;
|
||
padding: 8px;
|
||
margin: 5px 0;
|
||
border: 1px solid #ccc;
|
||
font-family: inherit;
|
||
}
|
||
|
||
button {
|
||
background: #f0f0f0;
|
||
cursor: pointer;
|
||
width: auto;
|
||
padding: 10px 20px;
|
||
}
|
||
|
||
button:hover {
|
||
background: #e0e0e0;
|
||
}
|
||
|
||
.json-display {
|
||
background: #f8f8f8;
|
||
border: 1px solid #ddd;
|
||
padding: 10px;
|
||
font-family: 'Courier New', monospace;
|
||
font-size: 12px;
|
||
white-space: pre-wrap;
|
||
word-wrap: break-word;
|
||
max-height: 400px;
|
||
overflow-y: auto;
|
||
margin: 10px 0;
|
||
}
|
||
|
||
.bounce-section {
|
||
background: #f9f9f9;
|
||
}
|
||
|
||
.hidden {
|
||
display: none;
|
||
}
|
||
|
||
.input-group {
|
||
margin: 10px 0;
|
||
}
|
||
|
||
label {
|
||
display: block;
|
||
font-weight: bold;
|
||
margin-bottom: 3px;
|
||
}
|
||
|
||
.small-input {
|
||
width: 200px;
|
||
}
|
||
|
||
#profile-picture {
|
||
width: 50px;
|
||
height: 50px;
|
||
border-radius: 25px;
|
||
}
|
||
|
||
/* Visualization Styles */
|
||
.visualization {
|
||
background: #f0f8ff;
|
||
border: 2px solid #4a90e2;
|
||
padding: 15px;
|
||
margin: 15px 0;
|
||
border-radius: 5px;
|
||
}
|
||
|
||
.timeline {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 15px;
|
||
}
|
||
|
||
.timeline-step {
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 10px;
|
||
border: 1px solid #ddd;
|
||
border-radius: 3px;
|
||
background: white;
|
||
}
|
||
|
||
.step-time {
|
||
min-width: 80px;
|
||
font-weight: bold;
|
||
color: #666;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.step-actor {
|
||
min-width: 100px;
|
||
font-weight: bold;
|
||
color: #2c5aa0;
|
||
}
|
||
|
||
.step-action {
|
||
flex: 1;
|
||
margin: 0 10px;
|
||
}
|
||
|
||
.step-size {
|
||
min-width: 80px;
|
||
text-align: right;
|
||
font-size: 12px;
|
||
color: #666;
|
||
}
|
||
|
||
.step-relays {
|
||
font-size: 11px;
|
||
color: #888;
|
||
font-style: italic;
|
||
}
|
||
|
||
/* Throw button hover effect */
|
||
.throw-button:hover {
|
||
background: #e8f4f8 !important;
|
||
}
|
||
</style>
|
||
</head>
|
||
|
||
<body>
|
||
|
||
<div id="login-section">
|
||
<!-- Login UI if needed -->
|
||
</div>
|
||
|
||
<div id="profile-section">
|
||
<h1>🏀 Superball Event Builder</h1>
|
||
<img id="profile-picture">
|
||
<div id="profile-name"></div>
|
||
<div id="profile-pubkey"></div>
|
||
<div id="profile-about"></div>
|
||
</div>
|
||
|
||
<div id="event-builder" class="hidden">
|
||
|
||
<!-- FINAL EVENT SECTION -->
|
||
<div class="section">
|
||
<h2>📝 Final Event (What gets posted at the end)</h2>
|
||
<div class="input-group">
|
||
<label for="final-content">Message Content:</label>
|
||
<textarea id="final-content" rows="3" placeholder="Enter your message content..."></textarea>
|
||
</div>
|
||
<button onclick="createFinalEvent()">Create Event That Will Be Published Publicly</button>
|
||
|
||
<div id="final-event-display" class="json-display"></div>
|
||
</div>
|
||
|
||
<!-- BOUNCES SECTION -->
|
||
<div id="bounces-container">
|
||
<!-- Bounce sections will be added here dynamically -->
|
||
</div>
|
||
|
||
<div class="section">
|
||
<button onclick="addBounce()">➕ Add Bounce</button>
|
||
<button onclick="resetBuilder()" style="background: #dc3545; color: white; margin-left: 10px;">🔄 Reset Builder</button>
|
||
</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 finalEvent = null;
|
||
let bounces = [];
|
||
let bounceCounter = 0;
|
||
|
||
// 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}`);
|
||
|
||
loadUserProfile();
|
||
document.getElementById('event-builder').classList.remove('hidden');
|
||
|
||
} 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...';
|
||
document.getElementById('profile-pubkey').textContent = userPubkey;
|
||
|
||
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');
|
||
const profile = JSON.parse(events[0].content);
|
||
displayProfile(profile);
|
||
} else {
|
||
console.log('INFO', 'No profile found');
|
||
document.getElementById('profile-name').textContent = 'No profile found';
|
||
document.getElementById('profile-about').textContent = 'User has not set up a profile yet.';
|
||
}
|
||
|
||
} catch (error) {
|
||
console.log('ERROR', `Profile loading failed: ${error.message}`);
|
||
document.getElementById('profile-name').textContent = 'Error loading profile';
|
||
}
|
||
}
|
||
|
||
function displayProfile(profile) {
|
||
const name = profile.name || profile.display_name || profile.displayName || 'Anonymous User';
|
||
const about = profile.about || '';
|
||
const picture = profile.picture || '';
|
||
|
||
document.getElementById('profile-name').textContent = name;
|
||
|
||
if (picture) {
|
||
document.getElementById('profile-picture').src = picture;
|
||
}
|
||
|
||
console.log('SUCCESS', `Profile displayed: ${name}`);
|
||
}
|
||
|
||
// Create final event (kind 1)
|
||
async function createFinalEvent() {
|
||
const content = document.getElementById('final-content').value.trim();
|
||
|
||
if (!content) {
|
||
alert('Please enter message content');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// Create the final event (kind 1) - pure message, no relay info
|
||
const eventTemplate = {
|
||
kind: 1,
|
||
content: content,
|
||
tags: [],
|
||
created_at: Math.floor(Date.now() / 1000)
|
||
};
|
||
|
||
// Sign the event using window.nostr (NIP-07)
|
||
finalEvent = await window.nostr.signEvent(eventTemplate);
|
||
|
||
// Display the final event (clean JSON without _targetRelays)
|
||
document.getElementById('final-event-display').textContent = JSON.stringify(finalEvent, null, 2);
|
||
|
||
console.log('SUCCESS', 'Final event created and signed');
|
||
|
||
// Reset bounces when final event is recreated
|
||
bounces = [];
|
||
document.getElementById('bounces-container').innerHTML = '';
|
||
|
||
} catch (error) {
|
||
console.log('ERROR', `Failed to create final event: ${error.message}`);
|
||
alert(`Failed to create final event: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
// Generate random audit tag (looks like pubkey)
|
||
function generateAuditTag() {
|
||
// Use the same method as generateSecretKey but get the pubkey
|
||
const randomKey = window.NostrTools.generateSecretKey();
|
||
return window.NostrTools.getPublicKey(randomKey);
|
||
}
|
||
|
||
|
||
// Generate random pubkey for testing (since we don't have real superballs yet)
|
||
function generateRandomPubkey(bounceId) {
|
||
const randomKey = window.NostrTools.generateSecretKey();
|
||
const randomPubkey = window.NostrTools.getPublicKey(randomKey);
|
||
document.getElementById(`superball-pubkey-${bounceId}`).value = randomPubkey;
|
||
console.log('Generated random test pubkey for bounce', bounceId, ':', randomPubkey);
|
||
}
|
||
|
||
// Decrypt and display bounce content for testing
|
||
function decryptBounce(bounceId) {
|
||
try {
|
||
// Find the bounce data
|
||
const bounce = bounces.find(b => b.id === bounceId);
|
||
if (!bounce) {
|
||
alert('Bounce data not found');
|
||
return;
|
||
}
|
||
|
||
// Get the routing event content (encrypted payload)
|
||
const routingEvent = bounce.routingEvent;
|
||
const encryptedContent = routingEvent.content;
|
||
|
||
// Get the ephemeral key used for encryption
|
||
const ephemeralKeyHex = bounce.ephemeralKey;
|
||
const superballPubkey = bounce.superballPubkey;
|
||
|
||
console.log('DEBUG: Decrypting bounce', bounceId);
|
||
console.log('DEBUG: Ephemeral key:', ephemeralKeyHex);
|
||
console.log('DEBUG: Superball pubkey:', superballPubkey);
|
||
console.log('DEBUG: Encrypted content length:', encryptedContent.length);
|
||
|
||
// Recreate conversation key
|
||
const conversationKey = window.NostrTools.nip44.v2.utils.getConversationKey(
|
||
ephemeralKeyHex,
|
||
superballPubkey
|
||
);
|
||
|
||
// Decrypt the content
|
||
const decryptedJson = window.NostrTools.nip44.v2.decrypt(
|
||
encryptedContent,
|
||
conversationKey
|
||
);
|
||
|
||
// Parse and display the decrypted payload
|
||
const decryptedPayload = JSON.parse(decryptedJson);
|
||
document.getElementById(`bounce-${bounceId}-decrypted-content`).textContent = JSON.stringify(decryptedPayload, null, 2);
|
||
document.getElementById(`bounce-${bounceId}-decrypted`).style.display = 'block';
|
||
|
||
console.log('SUCCESS: Bounce decrypted successfully');
|
||
console.log('Decrypted payload:', decryptedPayload);
|
||
|
||
} catch (error) {
|
||
console.log('ERROR', `Failed to decrypt bounce ${bounceId}: ${error.message}`);
|
||
alert(`Failed to decrypt bounce ${bounceId}: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
// Add a new bounce section
|
||
function addBounce() {
|
||
if (!finalEvent && bounces.length === 0) {
|
||
alert('Please create the final event first');
|
||
return;
|
||
}
|
||
|
||
bounceCounter++;
|
||
const bounceId = bounceCounter;
|
||
|
||
const bounceSection = document.createElement('div');
|
||
bounceSection.className = 'section bounce-section';
|
||
bounceSection.id = `bounce-${bounceId}`;
|
||
|
||
bounceSection.innerHTML = `
|
||
<h2 id="bounce-${bounceId}-header">🏀 Bounce ${bounceId} (Kind 22222 Routing Event)</h2>
|
||
<div class="input-group">
|
||
<label for="superball-pubkey-${bounceId}">Superball Pubkey:</label>
|
||
<div style="display: flex; gap: 10px;">
|
||
<input type="text" id="superball-pubkey-${bounceId}" placeholder="Superball's public key (64 hex chars)" value="03f857567fc96b47b68632457a818563c53e09aaf0028ac1081450afe3352e25" style="flex: 1;">
|
||
<button type="button" onclick="generateRandomPubkey(${bounceId})" style="width: auto; padding: 8px 12px;">🎲 Random</button>
|
||
</div>
|
||
</div>
|
||
<div class="input-group">
|
||
<label for="bounce-relays-${bounceId}">Target Relays (comma separated):</label>
|
||
<input type="text" id="bounce-relays-${bounceId}" placeholder="wss://relay1.com, wss://relay2.com" value="wss://relay.laantungir.net">
|
||
</div>
|
||
<div class="input-group">
|
||
<label for="delay-${bounceId}">Delay (seconds):</label>
|
||
<input type="number" id="delay-${bounceId}" class="small-input" value="30" min="1">
|
||
</div>
|
||
<div class="input-group">
|
||
<label for="padding-${bounceId}">Add Padding Bytes to This Event:</label>
|
||
<input type="number" id="padding-${bounceId}" class="small-input" placeholder="150" min="0" step="1">
|
||
</div>
|
||
<div class="input-group" id="daemon-padding-${bounceId}">
|
||
<label for="add-padding-bytes-${bounceId}">Instruct Next Daemon to Add Padding (bytes):</label>
|
||
<input type="number" id="add-padding-bytes-${bounceId}" class="small-input" placeholder="256" min="0" step="1">
|
||
<small style="color: #666; font-size: 12px;">Only used when forwarding to another Superball</small>
|
||
</div>
|
||
<div class="input-group">
|
||
<label for="payment-${bounceId}">Payment (optional):</label>
|
||
<input type="text" id="payment-${bounceId}" placeholder="eCash token or payment info">
|
||
</div>
|
||
<div class="input-group">
|
||
<label for="audit-tag-${bounceId}">Audit Tag (auto-generated):</label>
|
||
<input type="text" id="audit-tag-${bounceId}" readonly style="background: #f5f5f5;">
|
||
</div>
|
||
<button onclick="createBounce(${bounceId})" id="create-bounce-btn-${bounceId}">Create Bounce ${bounceId}</button>
|
||
|
||
<div id="bounce-${bounceId}-display" class="json-display"></div>
|
||
<div style="text-align: right; margin-top: 5px;">
|
||
<button onclick="decryptBounce(${bounceId})" id="decrypt-btn-${bounceId}" style="display:none; width:auto; padding:8px 15px;">🔓 Decrypt Content</button>
|
||
</div>
|
||
|
||
<div id="bounce-${bounceId}-decrypted" class="json-display" style="display:none;">
|
||
<h4>🔓 Decrypted Payload:</h4>
|
||
<div id="bounce-${bounceId}-decrypted-content"></div>
|
||
</div>
|
||
`;
|
||
|
||
document.getElementById('bounces-container').appendChild(bounceSection);
|
||
|
||
// Automatically generate and fill in the audit tag
|
||
const auditTag = generateAuditTag();
|
||
document.getElementById(`audit-tag-${bounceId}`).value = auditTag;
|
||
|
||
// Hide daemon padding field for final bounce (first one created)
|
||
if (bounces.length === 0) {
|
||
// This is the final bounce - hide daemon padding field since it makes no sense
|
||
const daemonPaddingDiv = document.getElementById(`daemon-padding-${bounceId}`);
|
||
if (daemonPaddingDiv) {
|
||
daemonPaddingDiv.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// Update bounce labels to reflect execution order
|
||
updateBounceLabels();
|
||
}
|
||
|
||
// Update bounce labels to reflect execution order (newest bounce is Bounce 1, oldest is Bounce N)
|
||
function updateBounceLabels() {
|
||
// Get all existing bounce sections
|
||
const bounceContainer = document.getElementById('bounces-container');
|
||
const bounceSections = bounceContainer.querySelectorAll('.bounce-section');
|
||
|
||
// Update labels in reverse order (newest first gets Bounce 1)
|
||
bounceSections.forEach((section, index) => {
|
||
const bounceId = section.id.replace('bounce-', '');
|
||
const executionOrder = bounceSections.length - index; // Reverse the index
|
||
|
||
// Update the header
|
||
const header = document.getElementById(`bounce-${bounceId}-header`);
|
||
if (header) {
|
||
header.textContent = `🏀 Bounce ${executionOrder} (Kind 22222 Routing Event)`;
|
||
}
|
||
|
||
// Update the create button text
|
||
const createBtn = document.getElementById(`create-bounce-btn-${bounceId}`);
|
||
if (createBtn) {
|
||
createBtn.textContent = `Create Bounce ${executionOrder}`;
|
||
}
|
||
});
|
||
|
||
console.log('INFO: Updated bounce labels for execution order');
|
||
}
|
||
|
||
// Update all timeline absolute times continuously
|
||
function updateAllTimelineTimes() {
|
||
// Recalculate and update absolute times in the visualization timeline
|
||
if (bounces.length === 0 || !finalEvent) {
|
||
return;
|
||
}
|
||
|
||
// Recalculate the current event flow with updated timestamps
|
||
const currentEventFlow = calculateEventFlow();
|
||
|
||
// Update each timeline step
|
||
const timelineSteps = document.querySelectorAll('.step-time-absolute');
|
||
|
||
timelineSteps.forEach((element, index) => {
|
||
if (currentEventFlow[index]) {
|
||
const newTime = formatAbsoluteTime(currentEventFlow[index].time);
|
||
element.textContent = newTime;
|
||
}
|
||
});
|
||
}
|
||
|
||
// Start continuous time updates
|
||
function startTimeUpdates() {
|
||
// Update every second
|
||
setInterval(updateAllTimelineTimes, 1000);
|
||
}
|
||
|
||
// Create a bounce event
|
||
async function createBounce(bounceId) {
|
||
const superballPubkey = document.getElementById(`superball-pubkey-${bounceId}`).value.trim();
|
||
const bounceRelays = document.getElementById(`bounce-relays-${bounceId}`).value.split(',').map(r => r.trim()).filter(r => r);
|
||
const delay = parseInt(document.getElementById(`delay-${bounceId}`).value);
|
||
const builderPadding = parseInt(document.getElementById(`padding-${bounceId}`).value) || 0;
|
||
const addPaddingBytes = parseInt(document.getElementById(`add-padding-bytes-${bounceId}`).value) || 0;
|
||
const payment = document.getElementById(`payment-${bounceId}`).value.trim();
|
||
|
||
// Debug the input values
|
||
console.log('DEBUG: Input values:');
|
||
console.log(' - superballPubkey:', superballPubkey);
|
||
console.log(' - bounceRelays:', bounceRelays);
|
||
console.log(' - delay:', delay);
|
||
console.log(' - builderPadding:', builderPadding);
|
||
console.log(' - addPaddingBytes:', addPaddingBytes);
|
||
console.log(' - payment:', `"${payment}"`);
|
||
|
||
if (!superballPubkey || superballPubkey.length !== 64) {
|
||
alert('Please enter a valid superball pubkey (64 hex characters)');
|
||
return;
|
||
}
|
||
|
||
if (!bounceRelays.length) {
|
||
alert('Please enter at least one bounce relay');
|
||
return;
|
||
}
|
||
|
||
if (!delay || delay < 1) {
|
||
alert('Please enter a valid delay (minimum 1 second)');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// Determine what event to wrap
|
||
let eventToWrap;
|
||
let targetRelays;
|
||
let isLastBounce = (bounces.length === 0);
|
||
|
||
if (isLastBounce) {
|
||
// This is the first bounce - wrap the final event
|
||
eventToWrap = finalEvent;
|
||
targetRelays = bounceRelays; // Use the bounce relays for final posting
|
||
} else {
|
||
// This bounce wraps the previous bounce
|
||
eventToWrap = bounces[bounces.length - 1].routingEvent;
|
||
targetRelays = bounceRelays;
|
||
}
|
||
|
||
// Use the pre-generated audit tag from the field
|
||
const auditTag = document.getElementById(`audit-tag-${bounceId}`).value;
|
||
|
||
// Create routing instructions
|
||
const routingInstructions = {
|
||
relays: targetRelays,
|
||
delay: delay,
|
||
audit: auditTag
|
||
};
|
||
|
||
// Add daemon instruction to add padding when forwarding (if specified and not final bounce)
|
||
if (!isLastBounce && addPaddingBytes > 0) {
|
||
routingInstructions.add_padding_bytes = addPaddingBytes;
|
||
console.log('DEBUG: Added add_padding_bytes instruction:', addPaddingBytes);
|
||
}
|
||
|
||
if (payment) {
|
||
routingInstructions.payment = payment;
|
||
}
|
||
|
||
console.log('DEBUG: Final routing instructions:', routingInstructions);
|
||
|
||
// Only add 'p' field if this isn't the last bounce
|
||
if (!isLastBounce) {
|
||
// Get the superball pubkey from the previous bounce (the next hop in the chain)
|
||
const prevBounce = bounces[bounces.length - 1];
|
||
routingInstructions.p = prevBounce.superballPubkey;
|
||
}
|
||
// Note: If this IS the last bounce (first one created), no 'p' field means final posting
|
||
|
||
// Add builder padding to routing instructions (if specified)
|
||
if (builderPadding > 0) {
|
||
// Generate counting padding string (0123456789012...)
|
||
let paddingString = '';
|
||
for (let i = 0; i < builderPadding; i++) {
|
||
paddingString += (i % 10).toString();
|
||
}
|
||
routingInstructions.padding = paddingString;
|
||
|
||
console.log('DEBUG: Added', builderPadding, 'bytes of builder padding to routing instructions');
|
||
}
|
||
|
||
// Create the payload to encrypt
|
||
const payload = {
|
||
event: eventToWrap,
|
||
routing: routingInstructions
|
||
};
|
||
|
||
// Generate ephemeral key for this routing event
|
||
const ephemeralKey = window.NostrTools.generateSecretKey();
|
||
const ephemeralPubkey = window.NostrTools.getPublicKey(ephemeralKey);
|
||
|
||
// Encrypt payload using NIP-44
|
||
// Convert ephemeral key to hex - try different API methods
|
||
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('');
|
||
}
|
||
|
||
console.log('DEBUG: Ephemeral key hex:', ephemeralKeyHex);
|
||
console.log('DEBUG: Superball pubkey:', superballPubkey);
|
||
console.log('DEBUG: Payload size:', JSON.stringify(payload).length);
|
||
|
||
const conversationKey = window.NostrTools.nip44.v2.utils.getConversationKey(
|
||
ephemeralKeyHex,
|
||
superballPubkey
|
||
);
|
||
const encryptedContent = window.NostrTools.nip44.v2.encrypt(
|
||
JSON.stringify(payload),
|
||
conversationKey
|
||
);
|
||
|
||
console.log('DEBUG: Encryption successful, content length:', encryptedContent.length);
|
||
|
||
// Create routing event template
|
||
const routingEventTemplate = {
|
||
kind: 22222,
|
||
content: encryptedContent,
|
||
tags: [
|
||
['p', superballPubkey],
|
||
['p', auditTag] // Audit tag disguised as p tag
|
||
],
|
||
created_at: Math.floor(Date.now() / 1000)
|
||
};
|
||
|
||
// Sign the routing event with ephemeral key
|
||
const routingEvent = window.NostrTools.finalizeEvent(routingEventTemplate, ephemeralKey);
|
||
|
||
// Store the bounce
|
||
const bounce = {
|
||
id: bounceId,
|
||
superballPubkey: superballPubkey,
|
||
auditTag: auditTag,
|
||
routingEvent: routingEvent,
|
||
payload: payload,
|
||
ephemeralKey: ephemeralKeyHex
|
||
};
|
||
|
||
bounces.push(bounce);
|
||
|
||
// Display the bounce event
|
||
document.getElementById(`bounce-${bounceId}-display`).textContent = JSON.stringify(routingEvent, null, 2);
|
||
|
||
// Show the decrypt button now that bounce is created
|
||
document.getElementById(`decrypt-btn-${bounceId}`).style.display = 'inline-block';
|
||
|
||
// Generate and show visualization
|
||
generateVisualization();
|
||
|
||
console.log('SUCCESS', `Bounce ${bounceId} created successfully`);
|
||
|
||
// Update bounce labels after creation to reflect execution order
|
||
updateBounceLabels();
|
||
|
||
} catch (error) {
|
||
console.log('ERROR', `Failed to create bounce ${bounceId}: ${error.message}`);
|
||
alert(`Failed to create bounce ${bounceId}: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
// Generate event flow visualization
|
||
function generateVisualization() {
|
||
if (bounces.length === 0 || !finalEvent) {
|
||
return;
|
||
}
|
||
|
||
// Remove existing visualization
|
||
const existingViz = document.querySelector('.visualization');
|
||
if (existingViz) {
|
||
existingViz.remove();
|
||
}
|
||
|
||
// Calculate event flow
|
||
const eventFlow = calculateEventFlow();
|
||
|
||
// Create visualization container
|
||
const vizContainer = document.createElement('div');
|
||
vizContainer.className = 'visualization';
|
||
vizContainer.innerHTML = `
|
||
<h2>📊 Event Flow Visualization</h2>
|
||
<p>This shows how your message will travel through the Superball anonymity network:</p>
|
||
<div class="timeline">
|
||
${generateThrowButtonHtml()}
|
||
${generateTimelineHtml(eventFlow)}
|
||
</div>
|
||
`;
|
||
|
||
// Insert after bounces container
|
||
const bouncesContainer = document.getElementById('bounces-container');
|
||
bouncesContainer.parentNode.insertBefore(vizContainer, bouncesContainer.nextSibling);
|
||
}
|
||
|
||
// Generate the "Throw the Superball" button HTML
|
||
function generateThrowButtonHtml() {
|
||
return `
|
||
<div class="timeline-step throw-button" style="background: white; border: 2px solid #4a90e2; cursor: pointer;" onclick="throwSuperball()">
|
||
<div class="step-time">
|
||
<div class="step-time-relative">Ready</div>
|
||
<div class="step-time-absolute" style="font-size: 10px; color: #888;">Click to start</div>
|
||
</div>
|
||
<div class="step-actor" style="color: #2c5aa0; font-weight: bold;">Throw Superball</div>
|
||
<div class="step-action" style="font-weight: bold;">Publish your routing event to start the anonymity chain</div>
|
||
<div class="step-size">Click here</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Throw the Superball - publish the outermost routing event to start the chain
|
||
async function throwSuperball() {
|
||
if (!bounces.length || !finalEvent) {
|
||
alert('Please create your final event and at least one bounce before throwing the Superball');
|
||
return;
|
||
}
|
||
|
||
if (!userPubkey) {
|
||
alert('Please log in first');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// Get the last (outermost) bounce to publish - the most recently created bounce
|
||
const outermostBounce = bounces[bounces.length - 1];
|
||
const routingEvent = outermostBounce.routingEvent;
|
||
|
||
// Get relays to publish to - use the outermost bounce's target relays
|
||
const targetRelays = outermostBounce.payload.routing.relays;
|
||
|
||
if (!targetRelays || targetRelays.length === 0) {
|
||
alert('No target relays configured for the first bounce');
|
||
return;
|
||
}
|
||
|
||
console.log('INFO: Publishing Superball routing event to relays:', targetRelays);
|
||
console.log('DEBUG: Event to publish:', JSON.stringify(routingEvent, null, 2));
|
||
|
||
// Update the throw button to show it's in progress
|
||
const throwButton = document.querySelector('.throw-button');
|
||
const throwButtonText = throwButton.querySelector('.step-action');
|
||
const originalText = throwButtonText.textContent;
|
||
throwButtonText.textContent = 'Publishing routing event...';
|
||
throwButton.style.pointerEvents = 'none';
|
||
throwButton.style.opacity = '0.7';
|
||
|
||
// Create a pool for publishing
|
||
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 target relays simultaneously');
|
||
await Promise.any(pool.publish(targetRelays, routingEvent));
|
||
|
||
pool.close(targetRelays);
|
||
|
||
// Success! Update the UI
|
||
throwButtonText.innerHTML = '<strong>✅ Superball Thrown Successfully!</strong><br><small>Your message is now in the anonymity network</small>';
|
||
throwButton.style.background = '#d4edda';
|
||
throwButton.style.borderColor = '#28a745';
|
||
throwButton.onclick = null; // Disable further clicks
|
||
|
||
console.log('SUCCESS: Superball routing event published successfully to at least one relay');
|
||
|
||
} catch (aggregateError) {
|
||
pool.close(targetRelays);
|
||
|
||
console.log('ERROR: All relay publish attempts failed:', aggregateError.errors);
|
||
|
||
// Extract individual error messages
|
||
const errorMessages = aggregateError.errors.map((err, index) =>
|
||
`${targetRelays[index]}: ${err.message}`
|
||
).join(', ');
|
||
|
||
// Restore button state
|
||
throwButtonText.textContent = originalText;
|
||
throwButton.style.pointerEvents = '';
|
||
throwButton.style.opacity = '';
|
||
|
||
throw new Error('Failed to publish Superball to any relay: ' + errorMessages);
|
||
}
|
||
|
||
} catch (error) {
|
||
console.log('ERROR', `Failed to throw Superball: ${error.message}`);
|
||
|
||
// Restore button if it was modified
|
||
const throwButton = document.querySelector('.throw-button');
|
||
if (throwButton) {
|
||
const throwButtonText = throwButton.querySelector('.step-action');
|
||
if (throwButtonText && throwButtonText.textContent.includes('Publishing')) {
|
||
throwButtonText.textContent = 'Publish your routing event to start the anonymity chain';
|
||
throwButton.style.pointerEvents = '';
|
||
throwButton.style.opacity = '';
|
||
}
|
||
}
|
||
|
||
alert(`Failed to throw Superball: ${error.message}\n\nPlease check your internet connection and relay configuration, then try again.`);
|
||
}
|
||
}
|
||
|
||
// Calculate the complete event flow with timing and sizes
|
||
function calculateEventFlow() {
|
||
const flow = [];
|
||
const baseTime = Date.now();
|
||
|
||
// Get the user's name from the profile section
|
||
const userNameElement = document.getElementById('profile-name');
|
||
const userName = userNameElement && userNameElement.textContent && userNameElement.textContent !== 'Loading profile...' && userNameElement.textContent !== 'No profile found' && userNameElement.textContent !== 'Error loading profile'
|
||
? userNameElement.textContent
|
||
: 'User';
|
||
|
||
// Start with user sending the outermost bounce
|
||
let currentTime = baseTime;
|
||
|
||
// Work forward through bounces (in order they were created)
|
||
bounces.forEach((bounce, index) => {
|
||
const bounceNumber = index + 1;
|
||
const isFirst = (index === 0);
|
||
const isLast = (index === bounces.length - 1);
|
||
|
||
if (isFirst) {
|
||
// User sends the outermost routing event (first bounce created)
|
||
const routingEventSize = JSON.stringify(bounce.routingEvent).length;
|
||
const relays = getRelaysForBounce(bounceNumber);
|
||
|
||
// Step 1: User sends to relay
|
||
flow.push({
|
||
time: currentTime,
|
||
actor: userName,
|
||
action: `Publishes routing event`,
|
||
size: routingEventSize
|
||
});
|
||
|
||
// Step 2: Relay propagates (immediate)
|
||
currentTime += 2000; // 2 seconds for relay propagation
|
||
flow.push({
|
||
time: currentTime,
|
||
actor: `Relay (${relays.join(', ')})`,
|
||
action: `Event available for Superball ${bounceNumber}`,
|
||
size: routingEventSize
|
||
});
|
||
}
|
||
|
||
// Add the delay for this superball to process
|
||
const delayMs = getDelayForBounce(bounceNumber) * 1000;
|
||
currentTime += delayMs;
|
||
|
||
// Superball processes and forwards
|
||
const paddingAdjustment = getPaddingAdjustmentForBounce(bounceNumber);
|
||
|
||
if (isLast) {
|
||
// Last bounce - posts final event
|
||
const finalEventSize = JSON.stringify(finalEvent).length + paddingAdjustment;
|
||
const finalRelays = getRelaysForBounce(bounceNumber);
|
||
|
||
// Step 3: Superball decrypts and sends final message
|
||
flow.push({
|
||
time: currentTime,
|
||
actor: `Superball ${bounceNumber}`,
|
||
action: `Decrypts and publishes your original message`,
|
||
size: Math.max(finalEventSize, 0)
|
||
});
|
||
|
||
// Step 4: Final relay makes message public
|
||
currentTime += 2000; // 2 seconds for relay propagation
|
||
flow.push({
|
||
time: currentTime,
|
||
actor: `Relay (${finalRelays.join(', ')})`,
|
||
action: `Your message is now publicly visible`,
|
||
size: Math.max(finalEventSize, 0)
|
||
});
|
||
} else {
|
||
// Intermediate bounce - forwards to next superball
|
||
const nextBounce = bounces[index + 1];
|
||
const nextRoutingSize = JSON.stringify(nextBounce.routingEvent).length + paddingAdjustment;
|
||
const nextRelays = getRelaysForBounce(bounceNumber + 1); // Next superball's relays
|
||
|
||
// Step 3: Superball forwards to next relay
|
||
flow.push({
|
||
time: currentTime,
|
||
actor: `Superball ${bounceNumber}`,
|
||
action: `Forwards routing event to next hop`,
|
||
size: Math.max(nextRoutingSize, 0)
|
||
});
|
||
|
||
// Step 4: Relay propagates to next superball
|
||
currentTime += 2000; // 2 seconds for relay propagation
|
||
flow.push({
|
||
time: currentTime,
|
||
actor: `Relay (${nextRelays.join(', ')})`,
|
||
action: `Event available for Superball ${bounceNumber + 1}`,
|
||
size: Math.max(nextRoutingSize, 0)
|
||
});
|
||
}
|
||
});
|
||
|
||
return flow;
|
||
}
|
||
|
||
// Generate HTML for timeline visualization
|
||
function generateTimelineHtml(eventFlow) {
|
||
return eventFlow.map((step, index) => `
|
||
<div class="timeline-step" id="timeline-step-${index}">
|
||
<div class="step-time">
|
||
<div class="step-time-relative">${formatTime(step.time)}</div>
|
||
<div class="step-time-absolute" style="font-size: 10px; color: #888;">${formatAbsoluteTime(step.time)}</div>
|
||
</div>
|
||
<div class="step-actor">${step.actor}</div>
|
||
<div class="step-action">${step.action}</div>
|
||
<div class="step-size">${step.size} bytes</div>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
// Format absolute time (HH:MM:SS)
|
||
function formatAbsoluteTime(timestamp) {
|
||
const date = new Date(timestamp);
|
||
return date.toLocaleTimeString([], {
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
second: '2-digit'
|
||
});
|
||
}
|
||
|
||
// Helper functions for bounce data extraction
|
||
function getRelaysForBounce(bounceNumber) {
|
||
const bounceIndex = bounceNumber - 1;
|
||
if (bounceIndex < 0 || bounceIndex >= bounces.length) return [];
|
||
|
||
// Get relays from the bounce's routing instructions
|
||
const bounce = bounces[bounceIndex];
|
||
if (bounce.payload && bounce.payload.routing && bounce.payload.routing.relays) {
|
||
return bounce.payload.routing.relays;
|
||
}
|
||
return ['Unknown relay'];
|
||
}
|
||
|
||
function getDelayForBounce(bounceNumber) {
|
||
const bounceIndex = bounceNumber - 1;
|
||
if (bounceIndex < 0 || bounceIndex >= bounces.length) return 30;
|
||
|
||
const bounce = bounces[bounceIndex];
|
||
if (bounce.payload && bounce.payload.routing && bounce.payload.routing.delay) {
|
||
return bounce.payload.routing.delay;
|
||
}
|
||
return 30; // default delay
|
||
}
|
||
|
||
function getPaddingAdjustmentForBounce(bounceNumber) {
|
||
const bounceIndex = bounceNumber - 1;
|
||
if (bounceIndex < 0 || bounceIndex >= bounces.length) return 0;
|
||
|
||
const bounce = bounces[bounceIndex];
|
||
if (bounce.payload && bounce.payload.routing && bounce.payload.routing.padding) {
|
||
const padding = bounce.payload.routing.padding;
|
||
if (typeof padding === 'string' && padding.match(/^[+-]\d+$/)) {
|
||
const match = padding.match(/^([+-])(\d+)$/);
|
||
const operation = match[1];
|
||
const bytes = parseInt(match[2]);
|
||
return operation === '+' ? bytes : -bytes;
|
||
} else if (typeof padding === 'string') {
|
||
// Actual padding string - return its length
|
||
return padding.length;
|
||
}
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
// Format timestamp for display
|
||
function formatTime(timestamp) {
|
||
const date = new Date(timestamp);
|
||
const now = new Date();
|
||
const diffSeconds = Math.floor((timestamp - now.getTime()) / 1000);
|
||
|
||
if (diffSeconds <= 0) {
|
||
return 'Now';
|
||
} else if (diffSeconds < 60) {
|
||
return `+${diffSeconds}s`;
|
||
} else if (diffSeconds < 3600) {
|
||
const minutes = Math.floor(diffSeconds / 60);
|
||
const seconds = diffSeconds % 60;
|
||
return `+${minutes}m ${seconds}s`;
|
||
} else {
|
||
const hours = Math.floor(diffSeconds / 3600);
|
||
const minutes = Math.floor((diffSeconds % 3600) / 60);
|
||
return `+${hours}h ${minutes}m`;
|
||
}
|
||
}
|
||
|
||
// Reset the builder to start over (keeps login session)
|
||
function resetBuilder() {
|
||
// Clear all global variables
|
||
finalEvent = null;
|
||
bounces = [];
|
||
bounceCounter = 0;
|
||
|
||
// Clear UI elements
|
||
document.getElementById('final-content').value = '';
|
||
document.getElementById('final-event-display').textContent = '';
|
||
document.getElementById('bounces-container').innerHTML = '';
|
||
|
||
// Remove visualization if it exists
|
||
const existingViz = document.querySelector('.visualization');
|
||
if (existingViz) {
|
||
existingViz.remove();
|
||
}
|
||
|
||
console.log('INFO: Builder reset successfully');
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
setTimeout(initializeApp, 100);
|
||
// Start the continuous time updates
|
||
startTimeUpdates();
|
||
});
|
||
</script>
|
||
</body>
|
||
|
||
</html> |