Files
nostr_quantum_preparation/www/verify.html
T

814 lines
30 KiB
HTML

<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; connect-src wss: https:; img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; object-src 'none'; base-uri 'none';" />
<title>Verify NIP-QR Event</title>
<link rel="stylesheet" href="./css/client.css" />
<link rel="shortcut icon" type="image/x-icon" href="data:image/x-icon;," />
<style>
#divBody {
flex-direction: column !important;
flex-wrap: nowrap !important;
align-items: center !important;
justify-content: flex-start !important;
align-content: flex-start !important;
}
.pq-container {
max-width: 760px;
width: 100%;
padding: 10px;
}
.pq-card {
background: var(--secondary-color);
border: var(--border);
border-radius: var(--border-radius);
padding: 25px;
margin-bottom: 20px;
}
.pq-card-title {
font-size: 18px;
font-weight: bold;
color: var(--primary-color);
margin-bottom: 15px;
}
.pq-info-text {
font-size: 14px;
color: var(--primary-color);
line-height: 1.6;
margin-bottom: 15px;
}
.pq-info-text strong { color: var(--primary-color); }
.pq-button {
background: var(--primary-color);
color: var(--secondary-color);
border: var(--border-width) solid var(--primary-color);
border-radius: var(--border-radius);
padding: 12px 24px;
font-size: 16px;
font-weight: bold;
cursor: pointer;
transition: opacity 0.2s;
width: 100%;
}
.pq-button:hover { opacity: 0.7; }
.pq-button:disabled { opacity: 0.3; cursor: not-allowed; }
.pq-button-row {
display: flex;
gap: 10px;
margin-top: 10px;
}
.pq-button-row .pq-button { width: auto; }
.pq-input {
width: 100%;
background: var(--secondary-color);
border: var(--border);
border-radius: var(--border-radius);
padding: 12px;
font-size: 14px;
color: var(--primary-color);
margin: 10px 0;
box-sizing: border-box;
}
.pq-input:focus {
outline: none;
border-color: var(--accent-color);
}
.pq-textarea {
width: 100%;
background: var(--secondary-color);
border: var(--border);
border-radius: var(--border-radius);
padding: 12px;
font-size: 12px;
color: var(--primary-color);
margin: 10px 0;
min-height: 160px;
resize: vertical;
font-family: monospace;
box-sizing: border-box;
}
.pq-textarea:focus {
outline: none;
border-color: var(--accent-color);
}
.pq-status {
padding: 10px 15px;
border-radius: var(--border-radius);
margin: 10px 0;
font-size: 14px;
text-align: left;
}
.pq-status-info {
background: var(--secondary-color);
border: var(--border);
}
.pq-status-success {
background: var(--accent-color);
color: var(--secondary-color);
}
.pq-status-error {
background: var(--accent-color);
color: var(--secondary-color);
}
.pq-result-list {
display: flex;
flex-direction: column;
gap: 8px;
margin: 15px 0;
}
.pq-result-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 15px;
background: var(--primary-color);
color: var(--secondary-color);
border-radius: var(--border-radius);
font-size: 14px;
}
.pq-result-valid {
border-left: 4px solid #00aa00;
}
.pq-result-invalid {
border-left: 4px solid #cc0000;
}
.pq-event-preview {
background: var(--primary-color);
color: var(--secondary-color);
border-radius: var(--border-radius);
padding: 15px;
font-size: 11px;
white-space: pre-wrap;
word-break: break-all;
max-height: 400px;
overflow-y: auto;
text-align: left;
margin-top: 10px;
}
.pq-link {
color: var(--accent-color);
text-decoration: underline;
cursor: pointer;
}
.pq-tabs {
display: flex;
gap: 4px;
margin-bottom: 15px;
border-bottom: var(--border);
}
.pq-tab {
padding: 10px 20px;
cursor: pointer;
font-size: 14px;
font-weight: bold;
color: var(--muted-color);
border-bottom: 3px solid transparent;
transition: color 0.2s, border-color 0.2s;
}
.pq-tab:hover { color: var(--primary-color); }
.pq-tab.pq-tab-active {
color: var(--primary-color);
border-bottom-color: var(--primary-color);
}
.pq-tab-panel { display: none; }
.pq-tab-panel.pq-tab-panel-active { display: block; }
.pq-ots-badge {
display: inline-block;
padding: 3px 10px;
border-radius: var(--border-radius);
font-size: 12px;
font-weight: bold;
margin-left: 8px;
}
.pq-ots-badge-pending {
background: #f0ad4e;
color: #fff;
}
.pq-ots-badge-confirmed {
background: #00aa00;
color: #fff;
}
.pq-ots-badge-none {
background: var(--muted-color);
color: var(--secondary-color);
}
</style>
</head>
<body>
<!-- HEADER -->
<div id="divHeader">
<div id="divHeaderFlexLeft"></div>
<div id="divHeaderFlexCenter">
<div class="divHeaderText">Verify NIP-QR Event</div>
</div>
<div id="divHeaderFlexRight"></div>
</div>
<!-- BODY -->
<div id="divBody">
<div class="pq-container">
<div class="pq-card">
<div class="pq-card-title">Verify NIP-QR Migration Event</div>
<div class="pq-info-text">
Verify post-quantum migration events (kind 11112) by querying relays for a user's pubkey,
or by pasting event JSON directly. The kind 11112 event wraps a kind 1 announcement
(embedded in its content as JSON) and carries an OpenTimestamps proof. Verification checks:
the kind 11112 secp256k1 signature, the embedded kind 1 event's secp256k1 signature,
the e tag (kind 1 event ID), the sha256 tag (full kind 1 event hash), all PQ signatures
(ML-DSA-44, ML-DSA-65, SLH-DSA-128s, Falcon-512, ML-KEM-768), and the OpenTimestamps proof.
</div>
<div class="pq-info-text">
<a href="./" class="pq-link">Back to migration page</a>
</div>
<div class="pq-tabs">
<div class="pq-tab pq-tab-active" id="pqTabRelay">Query Relay</div>
<div class="pq-tab" id="pqTabPaste">Paste Event JSON</div>
</div>
<!-- TAB 1: Query Relay -->
<div class="pq-tab-panel pq-tab-panel-active" id="pqPanelRelay">
<div class="pq-info-text">
Enter a Nostr pubkey (hex or npub) and one or more relay URLs. The page will query the
relay(s) for the latest kind 11112 event from that pubkey and verify it.
</div>
<input type="text" class="pq-input" id="pqPubkeyInput"
placeholder="Pubkey (hex or npub), e.g. 3bf0c63f869a8972... or npub1..." />
<input type="text" class="pq-input" id="pqRelayInput"
value="wss://laantungir.net/relay,wss://relay.damus.io,wss://nos.lol"
placeholder="wss://relay1.com,wss://relay2.com" />
<button class="pq-button" id="pqQueryBtn">Query & Verify</button>
<div id="pqQueryStatus"></div>
</div>
<!-- TAB 2: Paste Event JSON -->
<div class="pq-tab-panel" id="pqPanelPaste">
<div class="pq-info-text">
Paste a kind 11112 event JSON below to verify all signatures.
</div>
<textarea class="pq-textarea" id="pqEventInput" placeholder='Paste kind 11112 event JSON here, e.g.:
{
"id": "...",
"pubkey": "...",
"kind": 11112,
"content": "{\"id\":\"...\",\"pubkey\":\"...\",\"kind\":1,\"content\":\"I am signaling...\",\"tags\":[...],\"sig\":\"...\"}",
"tags": [
["e", "<kind 1 event id>"],
["sha256", "<hash of full kind 1 event>"],
["ots", "<base64 .ots proof>"]
],
"sig": "..."
}'></textarea>
<button class="pq-button" id="pqVerifyBtn">Verify Signatures</button>
<div id="pqVerifyStatus"></div>
</div>
<!-- Shared results area -->
<div id="pqResultsWrap" style="display: none;">
<div class="pq-info-text" style="margin-top: 15px; margin-bottom: 5px;">
<strong>Verification Results:</strong>
<span id="pqOtsBadge"></span>
</div>
<div class="pq-result-list" id="pqResultList"></div>
</div>
<div id="pqEventDisplayWrap" style="display: none; margin-top: 15px;">
<div class="pq-info-text" style="margin-bottom: 5px;"><strong>Event content:</strong></div>
<div class="pq-event-preview" id="pqEventContent"></div>
</div>
<div id="pqEventJsonWrap" style="display: none; margin-top: 15px;">
<div class="pq-info-text" style="margin-bottom: 5px;"><strong>Full event JSON:</strong></div>
<div class="pq-event-preview" id="pqEventJson"></div>
</div>
<!-- OTS upgrade section -->
<div id="pqOtsUpgradeWrap" style="display: none; margin-top: 15px;">
<div class="pq-info-text" style="margin-bottom: 5px;"><strong>OpenTimestamps:</strong></div>
<div id="pqOtsInfo" class="pq-status pq-status-info"></div>
<div class="pq-button-row">
<button class="pq-button" id="pqOtsUpgradeBtn">Upgrade OTS Proof</button>
<button class="pq-button" id="pqOtsRepublishBtn" style="display:none;">Republish with Confirmed Proof</button>
</div>
<div id="pqOtsUpgradeStatus"></div>
</div>
</div>
</div>
</div>
<!-- FOOTER -->
<div id="divFooter">
<div id="divFooterLeft" class="divFooterBox"></div>
<div id="divFooterCenter" class="divFooterBox"></div>
<div id="divFooterRight" class="divFooterBox"></div>
</div>
<!-- SCRIPTS -->
<script src="/nostr-login-lite/nostr.bundle.js"></script>
<script src="/nostr-login-lite/nostr-lite.js"></script>
<script type="module">
import {
verifyNIPQRContent,
verifyNostrEvent,
NIP_QR_KIND,
buildUpgradedEvent,
bytesToBase64,
base64ToBytes,
hexToBytes,
bytesToHex,
isOtsConfirmed,
verifyOtsProof,
upgradeOts,
isDetachedOtsFile
} from './pq-crypto.bundle.js';
/* ================================================================
TAB MANAGEMENT
================================================================ */
const tabRelay = document.getElementById('pqTabRelay');
const tabPaste = document.getElementById('pqTabPaste');
const panelRelay = document.getElementById('pqPanelRelay');
const panelPaste = document.getElementById('pqPanelPaste');
function switchTab(which) {
const isRelay = which === 'relay';
tabRelay.classList.toggle('pq-tab-active', isRelay);
tabPaste.classList.toggle('pq-tab-active', !isRelay);
panelRelay.classList.toggle('pq-tab-panel-active', isRelay);
panelPaste.classList.toggle('pq-tab-panel-active', !isRelay);
}
tabRelay.addEventListener('click', () => switchTab('relay'));
tabPaste.addEventListener('click', () => switchTab('paste'));
/* ================================================================
SHARED DOM + STATE
================================================================ */
const resultsWrap = document.getElementById('pqResultsWrap');
const resultList = document.getElementById('pqResultList');
const eventDisplayWrap = document.getElementById('pqEventDisplayWrap');
const eventContent = document.getElementById('pqEventContent');
const eventJsonWrap = document.getElementById('pqEventJsonWrap');
const eventJsonEl = document.getElementById('pqEventJson');
const otsBadge = document.getElementById('pqOtsBadge');
const otsUpgradeWrap = document.getElementById('pqOtsUpgradeWrap');
const otsInfo = document.getElementById('pqOtsInfo');
const otsUpgradeBtn = document.getElementById('pqOtsUpgradeBtn');
const otsRepublishBtn = document.getElementById('pqOtsRepublishBtn');
const otsUpgradeStatus = document.getElementById('pqOtsUpgradeStatus');
let currentEvent = null;
let currentOtsBytes = null;
// F-H3: Build status DOM safely — type is internally controlled, message uses textContent
function setStatus(element, type, message) {
const div = document.createElement('div');
div.className = `pq-status pq-status-${type}`;
div.textContent = message;
element.innerHTML = '';
element.appendChild(div);
}
function addResult(algorithm, valid, detail) {
const item = document.createElement('div');
item.className = `pq-result-item ${valid ? 'pq-result-valid' : 'pq-result-invalid'}`;
item.textContent = `${valid ? 'PASS' : 'FAIL'}${algorithm}${detail ? ': ' + detail : ''}`;
resultList.appendChild(item);
}
// F-H3: Helper to set OTS badge (static HTML, safe) and info (textContent, safe)
function setOtsBadge(className, text) {
otsBadge.innerHTML = `<span class="pq-ots-badge ${className}"></span>`;
otsBadge.querySelector('span').textContent = text;
}
function setOtsInfo(text) {
otsInfo.textContent = text;
}
/* ================================================================
NPUB <-> HEX CONVERSION (NIP-19)
================================================================ */
function npubToHex(npub) {
try {
const decoded = window.NostrTools.nip19.decode(npub);
if (decoded.type === 'npub') return decoded.data;
} catch (e) { /* fall through */ }
return null;
}
function hexToNpub(hex) {
try {
return window.NostrTools.nip19.npubEncode(hex);
} catch (e) { return hex; }
}
function normalizePubkey(input) {
const trimmed = input.trim();
if (/^[0-9a-fA-F]{64}$/.test(trimmed)) return trimmed.toLowerCase();
if (trimmed.startsWith('npub1')) {
const hex = npubToHex(trimmed);
if (hex) return hex;
}
return null;
}
/* ================================================================
QUERY RELAY FOR KIND 11112 EVENT
================================================================ */
function queryRelayForEvent(pubkeyHex, relayUrl) {
return new Promise((resolve, reject) => {
try {
const ws = new WebSocket(relayUrl);
let resolved = false;
const timeout = setTimeout(() => {
if (!resolved) {
resolved = true;
try { ws.close(); } catch (e) {}
reject(new Error('timeout'));
}
}, 15000);
ws.onopen = () => {
// REQ for kind 11112, limit 1 (latest)
const subId = 'pq_verify_' + Math.random().toString(36).substring(7);
ws.send(JSON.stringify(['REQ', subId, { kinds: [NIP_QR_KIND], authors: [pubkeyHex], limit: 1 }]));
};
ws.onmessage = (msg) => {
try {
const data = JSON.parse(msg.data);
if (data[0] === 'EVENT') {
if (!resolved) {
resolved = true;
clearTimeout(timeout);
try { ws.close(); } catch (e) {}
resolve(data[2]);
}
} else if (data[0] === 'EOSE') {
if (!resolved) {
resolved = true;
clearTimeout(timeout);
try { ws.close(); } catch (e) {}
resolve(null);
}
}
} catch (e) {}
};
ws.onerror = () => {
if (!resolved) {
resolved = true;
clearTimeout(timeout);
reject(new Error('connection error'));
}
};
} catch (e) {
reject(e);
}
});
}
/* ================================================================
VERIFY EVENT (shared by both tabs)
================================================================ */
async function verifyEvent(event) {
currentEvent = event;
resultList.innerHTML = '';
resultsWrap.style.display = 'none';
eventDisplayWrap.style.display = 'none';
eventJsonWrap.style.display = 'none';
otsUpgradeWrap.style.display = 'none';
otsBadge.innerHTML = '';
// Display content (the embedded kind 1 event, pretty-printed) and full kind 11112 JSON
try {
const parsed = JSON.parse(event.content);
eventContent.textContent = JSON.stringify(parsed, null, 2);
} catch (e) {
eventContent.textContent = event.content;
}
eventDisplayWrap.style.display = 'block';
eventJsonEl.textContent = JSON.stringify(event, null, 2);
eventJsonWrap.style.display = 'block';
let allValid = true;
// 1. Verify kind 11112 secp256k1 signature
const secpValid = verifyNostrEvent(event);
addResult('secp256k1 (kind 11112 wrapper)', secpValid, secpValid ? 'valid' : 'INVALID');
if (!secpValid) allValid = false;
// 2. Verify kind 1 sig, PQ sigs, e tag, sha256 tag
const pqResults = verifyNIPQRContent(event.content, event.tags);
for (const r of pqResults.results) {
addResult(r.algorithm, r.valid, r.note || (r.valid ? 'valid' : 'INVALID'));
if (!r.valid) allValid = false;
}
// 3. Inspect OTS proof tag and verify it matches the sha256 tag.
// First do a quick structural check for immediate UI feedback, then
// run full cryptographic verification (async — fetches Bitcoin block
// headers and validates the Merkle path).
const otsTag = event.tags.find(t => t[0] === 'ots');
const sha256Tag = event.tags.find(t => t[0] === 'sha256');
if (otsTag && otsTag[1]) {
try {
currentOtsBytes = base64ToBytes(otsTag[1]);
const hasBitcoinAttestation = isOtsConfirmed(currentOtsBytes);
const validFile = isDetachedOtsFile(currentOtsBytes);
const fullHash = pqResults.fullHash;
const hashMatchNote = (sha256Tag && fullHash)
? (sha256Tag[1].toLowerCase() === fullHash.toLowerCase()
? `Hash matches full kind 1 event: ${fullHash.substring(0, 16)}...`
: `WARNING: sha256 tag (${sha256Tag[1].substring(0, 16)}...) does not match computed hash (${fullHash.substring(0, 16)}...)`)
: 'No sha256 tag found';
// Quick structural display first
if (!validFile) {
setOtsBadge('pq-ots-badge-none', 'OTS: Invalid format');
setOtsInfo(`OTS tag present but does not appear to be a valid detached .ots file (${currentOtsBytes.length} bytes). ${hashMatchNote}.`);
otsUpgradeWrap.style.display = 'none';
} else if (hasBitcoinAttestation) {
setOtsBadge('pq-ots-badge-confirmed', 'OTS: Verifying...');
setOtsInfo(`OpenTimestamps proof contains a Bitcoin attestation. Performing full cryptographic verification (fetching block header)... Proof size: ${currentOtsBytes.length} bytes. ${hashMatchNote}.`);
otsUpgradeWrap.style.display = 'block';
otsUpgradeBtn.style.display = 'none';
otsRepublishBtn.style.display = 'none';
// Run full async verification: parse the proof, bind the target
// digest to the sha256 tag, walk the Merkle path, and check the
// block header against a Bitcoin API.
verifyOtsProof(currentOtsBytes, sha256Tag ? sha256Tag[1] : undefined).then(result => {
if (result.verified && result.bitcoinAttestations.length > 0) {
const att = result.bitcoinAttestations[0];
const date = new Date(att.time * 1000).toISOString().substring(0, 19);
setOtsBadge('pq-ots-badge-confirmed', 'OTS: Verified (Bitcoin)');
setOtsInfo(`OpenTimestamps proof cryptographically verified. Bitcoin block ${att.height} (mined ${date} UTC). Merkle root matches. Proof commits to the event hash. Proof size: ${currentOtsBytes.length} bytes. ${hashMatchNote}.`);
otsUpgradeBtn.style.display = 'none';
} else {
setOtsBadge('pq-ots-badge-none', 'OTS: Verification failed');
const errDetail = result.errors.length > 0 ? ` Errors: ${result.errors.join('; ')}` : '';
setOtsInfo(`OpenTimestamps proof could NOT be cryptographically verified. ${hashMatchNote}.${errDetail}`);
otsUpgradeBtn.style.display = 'inline-block';
}
}).catch(err => {
setOtsBadge('pq-ots-badge-none', 'OTS: Verification error');
setOtsInfo(`OpenTimestamps verification error: ${err.message}. ${hashMatchNote}.`);
otsUpgradeBtn.style.display = 'inline-block';
});
} else {
setOtsBadge('pq-ots-badge-pending', 'OTS: Pending');
setOtsInfo(`OpenTimestamps proof is pending (no Bitcoin attestation yet). Proof size: ${currentOtsBytes.length} bytes. ${hashMatchNote}. You can try upgrading it below.`);
otsUpgradeWrap.style.display = 'block';
otsUpgradeBtn.style.display = 'inline-block';
otsRepublishBtn.style.display = 'none';
}
} catch (e) {
setOtsBadge('pq-ots-badge-none', 'OTS: Error');
setOtsInfo(`Failed to parse OTS proof: ${e.message}`);
otsUpgradeWrap.style.display = 'none';
}
} else {
setOtsBadge('pq-ots-badge-none', 'OTS: None');
setOtsInfo('No OpenTimestamps proof tag found in this event.');
otsUpgradeWrap.style.display = 'none';
}
resultsWrap.style.display = 'block';
return allValid;
}
/* ================================================================
TAB 1: QUERY RELAY
================================================================ */
const queryBtn = document.getElementById('pqQueryBtn');
const queryStatus = document.getElementById('pqQueryStatus');
queryBtn.addEventListener('click', async () => {
queryBtn.disabled = true;
setStatus(queryStatus, 'info', 'Querying relays...');
const pubkeyInput = document.getElementById('pqPubkeyInput').value.trim();
const relayInput = document.getElementById('pqRelayInput').value.trim();
const pubkeyHex = normalizePubkey(pubkeyInput);
if (!pubkeyHex) {
setStatus(queryStatus, 'error', 'Invalid pubkey. Enter a 64-char hex pubkey or an npub.');
queryBtn.disabled = false;
return;
}
const relayUrls = relayInput.split(',').map(s => s.trim()).filter(Boolean);
if (relayUrls.length === 0) {
setStatus(queryStatus, 'error', 'Enter at least one relay URL.');
queryBtn.disabled = false;
return;
}
let foundEvent = null;
let lastError = null;
let successCount = 0;
// Query relays in parallel, take the first event returned
const promises = relayUrls.map(async (url) => {
try {
const event = await queryRelayForEvent(pubkeyHex, url);
if (event) {
successCount++;
if (!foundEvent || (event.created_at > foundEvent.created_at)) {
foundEvent = event;
}
}
return { url, ok: true, event };
} catch (e) {
lastError = e.message;
return { url, ok: false, error: e.message };
}
});
await Promise.all(promises);
if (foundEvent) {
const npub = hexToNpub(pubkeyHex);
setStatus(queryStatus, 'success', `Found kind 11112 event from ${npub.substring(0, 20)}... (created_at: ${foundEvent.created_at}, queried ${successCount}/${relayUrls.length} relays).`);
await verifyEvent(foundEvent);
} else {
setStatus(queryStatus, 'error', `No kind 11112 event found for this pubkey on any of the ${relayUrls.length} relay(s)${lastError ? ' (last error: ' + lastError + ')' : ''}.`);
}
queryBtn.disabled = false;
});
/* ================================================================
TAB 2: PASTE EVENT JSON
================================================================ */
const verifyBtn = document.getElementById('pqVerifyBtn');
const eventInput = document.getElementById('pqEventInput');
const verifyStatus = document.getElementById('pqVerifyStatus');
verifyBtn.addEventListener('click', async () => {
verifyBtn.disabled = true;
setStatus(verifyStatus, 'info', 'Verifying...');
try {
const event = JSON.parse(eventInput.value.trim());
if (!event || !event.pubkey || !event.sig || !event.content || !event.tags) {
throw new Error('Invalid event: missing required fields (pubkey, sig, content, tags)');
}
if (event.kind !== NIP_QR_KIND) {
setStatus(verifyStatus, 'error', `Warning: event kind is ${event.kind}, expected ${NIP_QR_KIND}. Verifying anyway...`);
}
const allValid = await verifyEvent(event);
if (allValid) {
setStatus(verifyStatus, 'success', 'All signatures verified successfully!');
} else {
setStatus(verifyStatus, 'error', 'Some signatures failed verification.');
}
} catch (error) {
console.error('[verify] Error:', error);
setStatus(verifyStatus, 'error', `Error: ${error.message}`);
} finally {
verifyBtn.disabled = false;
}
});
/* ================================================================
OTS UPGRADE (from verify page)
================================================================ */
otsUpgradeBtn.addEventListener('click', async () => {
if (!currentOtsBytes) return;
otsUpgradeBtn.disabled = true;
setStatus(otsUpgradeStatus, 'info', 'Sending .ots proof to upgrade service...');
try {
const result = await upgradeOts(currentOtsBytes);
currentOtsBytes = result.proof;
// After upgrading, run full cryptographic verification to confirm
// the Bitcoin attestation is real (not just a byte-pattern match).
const sha256Tag = currentEvent && currentEvent.tags ? currentEvent.tags.find(t => t[0] === 'sha256') : null;
const verifyResult = await verifyOtsProof(currentOtsBytes, sha256Tag ? sha256Tag[1] : undefined);
const confirmed = verifyResult.verified;
if (confirmed) {
const att = verifyResult.bitcoinAttestations[0];
const date = att ? new Date(att.time * 1000).toISOString().substring(0, 19) : 'unknown';
setStatus(otsUpgradeStatus, 'success', `Bitcoin attestation verified! Block ${att ? att.height : '?'} (mined ${date} UTC). Proof upgraded (${currentOtsBytes.length} bytes). You can republish the event with the confirmed proof below.`);
otsUpgradeBtn.style.display = 'none';
otsRepublishBtn.style.display = 'inline-block';
setOtsBadge('pq-ots-badge-confirmed', 'OTS: Verified (Bitcoin)');
setOtsInfo(`OpenTimestamps proof cryptographically verified. Bitcoin block ${att ? att.height : '?'}. Proof size: ${currentOtsBytes.length} bytes.`);
} else {
setStatus(otsUpgradeStatus, 'info', `Proof upgraded but still pending (no Bitcoin attestation yet). ${result.detail ? 'Detail: ' + result.detail : ''} Try again later.`);
}
} catch (error) {
setStatus(otsUpgradeStatus, 'error', `Upgrade failed: ${error.message}`);
} finally {
otsUpgradeBtn.disabled = false;
}
});
otsRepublishBtn.addEventListener('click', async () => {
if (!currentEvent || !currentOtsBytes) return;
otsRepublishBtn.disabled = true;
setStatus(otsUpgradeStatus, 'info', 'Requesting secp256k1 signature for upgraded event...');
try {
if (!window.nostr || !window.nostr.signEvent) {
throw new Error('Nostr signer (window.nostr) not available. Use a NIP-07 signer extension to republish.');
}
const upgradedTemplate = buildUpgradedEvent(currentEvent, currentOtsBytes);
const signedEvent = await window.nostr.signEvent(upgradedTemplate);
// Publish to the relays from the relay input field
const relayInput = document.getElementById('pqRelayInput').value.trim();
const relayUrls = relayInput.split(',').map(s => s.trim()).filter(Boolean);
if (relayUrls.length === 0) {
throw new Error('No relay URLs specified in the relay input field.');
}
const eventJson = JSON.stringify(['EVENT', signedEvent]);
const publishResults = await Promise.all(relayUrls.map(url => {
return new Promise((resolve) => {
try {
const ws = new WebSocket(url);
let done = false;
const t = setTimeout(() => { if (!done) { done = true; try { ws.close(); } catch(e){} resolve({ url, success: false, error: 'timeout' }); } }, 15000);
ws.onopen = () => ws.send(eventJson);
ws.onmessage = (msg) => {
try {
const data = JSON.parse(msg.data);
if (data[0] === 'OK' && data[1] === signedEvent.id) {
if (!done) { done = true; clearTimeout(t); try { ws.close(); } catch(e){} resolve({ url, success: true }); }
}
} catch (e) {}
};
ws.onclose = () => { if (!done) { done = true; clearTimeout(t); resolve({ url, success: false, error: 'closed without OK' }); } };
ws.onerror = () => { if (!done) { done = true; clearTimeout(t); resolve({ url, success: false, error: 'error' }); } };
} catch (e) { resolve({ url, success: false, error: e.message }); }
});
}));
const ok = publishResults.filter(r => r.success).length;
const fail = publishResults.filter(r => !r.success).length;
if (ok === 0) {
throw new Error(`No relay accepted the upgraded event (${fail} failed).`);
}
currentEvent = signedEvent;
setStatus(otsUpgradeStatus, 'success', `Upgraded kind 11112 event published to ${ok} relay(s) (${fail} failed).`);
// Re-verify the new event
await verifyEvent(signedEvent);
} catch (error) {
setStatus(otsUpgradeStatus, 'error', `Republish failed: ${error.message}`);
} finally {
otsRepublishBtn.disabled = false;
}
});
</script>
</body>
</html>