G56-04: multi-explorer cross-check (fail on disagreement) + trustMode field in verifyOtsProof result (multi-explorer-checked/single-explorer-checked/structural-only) + UI trust-mode labels + remove misleading lite-client comments

This commit is contained in:
Laan Tungir
2026-07-24 10:15:01 -04:00
parent a1e1759704
commit f29f63950b
8 changed files with 159 additions and 41 deletions
+12 -3
View File
@@ -9,10 +9,10 @@
| Severity | Total | Fixed | In Progress | Not Started |
|---|---:|---:|---:|---:|
| Critical | 2 | 2 | 0 | 0 |
| High | 6 | 3 | 0 | 3 |
| High | 6 | 4 | 0 | 2 |
| Medium | 8 | 2 | 0 | 6 |
| Low / Info | 4 | 0 | 0 | 4 |
| **Total** | **20** | **7** | **0** | **13** |
| **Total** | **20** | **8** | **0** | **12** |
## Findings status
@@ -21,7 +21,7 @@
| G56-01 | Critical | Verifier accepts no PQ signatures and missing signatures as successful | ✅ Fixed | v0.0.12 | Strict mandatory algorithm policy, fail-closed on malformed input, structured result fields, 11 adversarial tests |
| G56-02 | Critical | Earliest-valid-anchor rule not implemented; relay discovery selects latest replaceable event | ✅ Fixed | v0.0.14v0.0.16 | Switched to non-replaceable kind 9999, append-only upgrades with `upgrade_of` tag, `selectCanonicalProofCarrier()` function, removed `limit: 1` from relay queries, updated all docs |
| G56-03 | High | Outer wrapper identity not bound to embedded identity | ✅ Fixed | v0.0.14 | Added `outerPubkey` and `expectedAuthor` options to `verifyNIPQRContent()`, `identityBound` field in result, `validForMigration` requires identity binding, 4 identity binding tests |
| G56-04 | High | Bitcoin verification trusts public API JSON, doesn't validate headers/PoW | ❌ Not started | — | Needs trust-mode labeling or real header validation |
| G56-04 | High | Bitcoin verification trusts public API JSON, doesn't validate headers/PoW | ✅ Fixed | v0.0.21 | Tier 1+2: rewrote `fetchBitcoinBlockHeader()` to query ALL providers and cross-check merkle roots (fail on disagreement); added `trustMode` field to `verifyOtsProof()` result (`multi-explorer-checked` / `single-explorer-checked` / `structural-only`); updated verify.html + index.html UI to display trust mode label; removed misleading "lite-client verification" / "independently verifiable" comments. Full light-client PoW verification (Tier 3) remains as future work. 2 test assertions added (trustMode present on verified proof, structural-only on pending) |
| G56-05 | High | Signer output reconstructed without validating returned ID/sig/pubkey/fields | ✅ Fixed | v0.0.20 | Added `validateSignerOutput()` helper: checks all required fields present + typed, verifies no template field (content/tags/created_at/kind) changed, verifies pubkey equals expected identity, recomputes + verifies event ID, verifies Schnorr signature, rejects extra fields. Replaced reconstruction at all 4 `signEvent()` call sites (2 in index.html, 2 in verify.html) with validation + retain-exact-signed-object. 15 new tests (mutated content/tags/kind/created_at, wrong pubkey, wrong ID, invalid sig, extra field, missing field, non-object, malformed id/sig, tag element changed) |
| G56-06 | High | New seed continuity asserted, not proven — no successor signature, no identity match | ❌ Not started | — | Protocol design decision needed |
| G56-07 | High | Verifier throws on malformed relay/paste input | ✅ Fixed | v0.0.12, v0.0.19 | v0.0.12 wrapped base64/hex in try/catch + verifyNIPQRContent fail closed + verify.html call-site wrapping. v0.0.19 added structural validation + try/catch to `verifyNostrEvent()` (returns false on malformed input), wrapped `parseOtsFile()` body in try/catch (returns null), added `MAX_OTS_PROOF_SIZE` (1 MiB) and `MAX_EVENT_JSON_SIZE` (64 KiB) size guards, 23 fuzz/property tests (random JSON, random bytes, truncation, oversized inputs, wrong-typed objects) |
@@ -115,6 +115,14 @@
- `verify.html`: upgrade (now validated)
- 15 new tests: valid output, mutated content/tags/kind/created_at, wrong pubkey, wrong ID, invalid sig, extra field, missing field, non-object, malformed id/sig, tag element changed, no-expectedPubkey backward compat
### v0.0.21 — G56-04 fix (Bitcoin trust-mode labeling + multi-explorer cross-check)
- Rewrote `fetchBitcoinBlockHeader()` to query ALL providers (blockstream + mempool) and cross-check their merkle roots; fails on disagreement (turns fallback-redundancy into a cross-check)
- Added `trustMode` field to `verifyOtsProof()` return value: `multi-explorer-checked` (both providers agreed), `single-explorer-checked` (only one responded), `structural-only` (no Bitcoin attestation verified)
- Updated verify.html (2 locations) and index.html (1 location) to display the trust mode label alongside verification results
- Removed misleading "lite-client verification" / "independently verifiable against the Bitcoin PoW chain" comments from code
- Full light-client PoW/difficulty/chain-linkage verification (Tier 3) remains as future work
- 2 new test assertions: trustMode present + explorer-checked on verified proof, structural-only on pending proof
## Test count
- **Before remediation:** 38 tests
@@ -124,4 +132,5 @@
- **After G56-16:** 65 tests (docs-only)
- **After G56-07:** 88 tests
- **After G56-05:** 103 tests
- **After G56-04:** 103 tests (2 assertions added to existing tests)
- **All passing:** ✅
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "post_quantum_nostr",
"version": "0.0.20",
"version": "0.0.21",
"description": "A migration strategy for bringing post-quantum security to Nostr without breaking the social graph, without requiring consensus on a single post-quantum algorithm, and without forcing existing users to abandon their identities.",
"main": "index.js",
"scripts": {
+6
View File
@@ -311,6 +311,10 @@ describe('OTS verifier (verifyOtsProof) — F-C3 binding', () => {
assert.equal(result.verified, true, 'should verify against Bitcoin block 358391');
assert.equal(result.bitcoinAttestations.length, 1);
assert.equal(result.bitcoinAttestations[0].height, 358391);
// G56-04: trustMode must be present and indicate explorer-checked trust
assert.ok(result.trustMode, 'trustMode field must be present');
assert.ok(['multi-explorer-checked', 'single-explorer-checked'].includes(result.trustMode),
`trustMode should be explorer-checked, got: ${result.trustMode}`);
});
test('F-C3: wrong expected digest fails with "Target digest mismatch"', async () => {
@@ -334,6 +338,8 @@ describe('OTS verifier (verifyOtsProof) — F-C3 binding', () => {
const result = await m.verifyOtsProof(ots);
assert.equal(result.verified, false);
assert.ok(result.errors.some(e => e.includes('no Bitcoin attestations') || e.includes('pending')), 'should report no Bitcoin attestations');
// G56-04: trustMode should be 'structural-only' when no Bitcoin attestation is verified
assert.equal(result.trustMode, 'structural-only', 'pending proof should have structural-only trust mode');
});
});
+7 -2
View File
@@ -1703,9 +1703,14 @@
if (verifyResult.verified) {
const att = verifyResult.bitcoinAttestations[0];
const date = att ? new Date(att.time * 1000).toISOString().substring(0, 19) : 'unknown';
otsLog(`Poll ${pollCount}: Bitcoin attestation VERIFIED (block ${att ? att.height : '?'}, mined ${date} UTC). Proof: ${pendingOtsBytes.length} bytes.`);
const trustLabel = verifyResult.trustMode === 'multi-explorer-checked'
? 'multi-explorer-checked'
: verifyResult.trustMode === 'single-explorer-checked'
? 'single-explorer-checked'
: verifyResult.trustMode || 'unknown';
otsLog(`Poll ${pollCount}: Bitcoin attestation VERIFIED (block ${att ? att.height : '?'}, mined ${date} UTC, trust: ${trustLabel}). Proof: ${pendingOtsBytes.length} bytes.`);
setKeyIcon('pqOtsConfirm', 'Done');
confirmDetail.textContent = `Verified on Bitcoin blockchain (block ${att ? att.height : '?'})!`;
confirmDetail.textContent = `Verified on Bitcoin (block ${att ? att.height : '?'}, ${trustLabel})`;
if (otsPollInterval) { clearInterval(otsPollInterval); otsPollInterval = null; }
const saved = loadPendingOts();
+81 -21
View File
@@ -1836,8 +1836,13 @@ function _classifyAttestation(tag, payload, digest, attestations) {
// ============================================================================
/**
* Bitcoin API endpoints for fetching block headers (for lite-client verification).
* We try multiple providers for resilience.
* Bitcoin block explorer API providers.
*
* G56-04: These are trusted public APIs, NOT a Bitcoin light-client. The block
* headers they return are NOT independently verified against PoW, difficulty, or
* chain linkage. We cross-check both providers and fail on disagreement to make
* coordinated false responses harder, but this is still "multi-explorer-checked"
* trust, not "header-chain-verified" or trustless.
*/
const BITCOIN_API_PROVIDERS = [
{ name: 'blockstream', blockHash: (h) => `https://blockstream.info/api/block-height/${h}`, block: (hash) => `https://blockstream.info/api/block/${hash}` },
@@ -1845,15 +1850,23 @@ const BITCOIN_API_PROVIDERS = [
];
/**
* Fetch a Bitcoin block header (merkle root + timestamp) from a public API.
* Uses lite-client verification: we trust the API for the block header, which
* is the standard OTS lite-client model (the block header is independently
* verifiable against the Bitcoin PoW chain).
* Fetch a Bitcoin block header (merkle root + timestamp) from public explorer APIs.
*
* G56-04: This is NOT lite-client verification. We trust the APIs for the block
* header data. To make coordinated false responses harder, we query ALL providers
* and require them to agree on the merkle root. If they disagree, we fail.
*
* Trust mode: 'multi-explorer-checked' if all queried providers agree.
* This is stronger than single-provider trust but is NOT trustless — a
* compromise of both providers could still falsify a result.
*
* @param {number} height - Bitcoin block height
* @returns {Promise<{merkleroot: string, time: number, height: number}>}
* @returns {Promise<{merkleroot: string, time: number, height: number, trustMode: string, providers: string[]}>}
* @throws {Error} if providers disagree or all fail
*/
async function fetchBitcoinBlockHeader(height) {
const results = [];
for (const provider of BITCOIN_API_PROVIDERS) {
try {
// 1. Get block hash from height
@@ -1869,37 +1882,74 @@ async function fetchBitcoinBlockHeader(height) {
if (!blockData.merkle_root && !blockData.merkleroot) continue;
if (!blockData.timestamp && !blockData.time) continue;
return {
merkleroot: blockData.merkle_root || blockData.merkleroot,
results.push({
provider: provider.name,
merkleroot: (blockData.merkle_root || blockData.merkleroot).toLowerCase(),
time: blockData.timestamp || blockData.time,
height
};
});
} catch (e) {
console.warn(`[ots] Bitcoin API ${provider.name} failed for height ${height}:`, e.message);
continue;
}
}
throw new Error(`Could not fetch Bitcoin block header for height ${height} from any provider`);
if (results.length === 0) {
throw new Error(`Could not fetch Bitcoin block header for height ${height} from any provider`);
}
// G56-04: Cross-check — if multiple providers responded, they must agree
if (results.length > 1) {
const firstRoot = results[0].merkleroot;
for (let i = 1; i < results.length; i++) {
if (results[i].merkleroot !== firstRoot) {
const providerList = results.map(r => `${r.provider}:${r.merkleroot.substring(0, 16)}...`).join(', ');
throw new Error(`Bitcoin providers disagree on merkle root for block ${height}: ${providerList}`);
}
}
}
// Determine trust mode based on how many providers agreed
const trustMode = results.length >= 2
? 'multi-explorer-checked'
: 'single-explorer-checked';
return {
merkleroot: results[0].merkleroot,
time: results[0].time,
height,
trustMode,
providers: results.map(r => r.provider)
};
}
/**
* Cryptographically verify an OTS proof against an expected target digest.
* Verify an OTS proof against an expected target digest.
*
* This performs REAL verification:
* G56-04: This performs Merkle-path verification against block headers obtained
* from public explorer APIs. The block headers are NOT independently verified
* against Bitcoin PoW/difficulty/chain-linkage — they are trusted from the APIs.
* The `trustMode` field in the result indicates the trust level:
* - 'multi-explorer-checked': multiple providers agreed on the merkle root
* - 'single-explorer-checked': only one provider responded
* - 'structural-only': no Bitcoin attestation was verified (pending/none)
*
* Steps:
* 1. Parses the OTS file (op stream, not byte-pattern search)
* 2. Checks the proof's target digest equals the expected digest (F-C3 fix)
* 3. Walks the op tree to compute the commitment at each attestation
* 4. For Bitcoin attestations: fetches the block header and checks the
* computed merkle root matches the block's merkle root
* 4. For Bitcoin attestations: fetches the block header (cross-checked across
* providers) and checks the computed merkle root matches
*
* @param {Uint8Array} otsBytes - The .ots file bytes
* @param {string} [expectedDigestHex] - The expected target digest (hex). If
* provided, the proof's target digest must match this exactly (F-C3 binding).
* @returns {Promise<{verified: boolean, targetDigest: string, attestations: Array, bitcoinAttestations: Array, errors: Array}>}
* - verified: true if at least one Bitcoin attestation is cryptographically valid
* @returns {Promise<{verified: boolean, targetDigest: string, attestations: Array, bitcoinAttestations: Array, trustMode: string, errors: Array}>}
* - verified: true if at least one Bitcoin attestation's merkle root matches
* - targetDigest: hex of the proof's target digest
* - attestations: all attestations found (parsed)
* - bitcoinAttestations: verified Bitcoin attestations with block time + height
* - trustMode: 'multi-explorer-checked' | 'single-explorer-checked' | 'structural-only'
* - errors: list of error messages (e.g., digest mismatch, pending only)
*/
export async function verifyOtsProof(otsBytes, expectedDigestHex) {
@@ -1910,10 +1960,10 @@ export async function verifyOtsProof(otsBytes, expectedDigestHex) {
try {
parsed = parseOtsFile(otsBytes);
} catch (e) {
return { verified: false, targetDigest: null, attestations: [], bitcoinAttestations: [], errors: [`Failed to parse OTS file: ${e.message}`] };
return { verified: false, targetDigest: null, attestations: [], bitcoinAttestations: [], trustMode: 'structural-only', errors: [`Failed to parse OTS file: ${e.message}`] };
}
if (!parsed) {
return { verified: false, targetDigest: null, attestations: [], bitcoinAttestations: [], errors: ['Invalid OTS file format'] };
return { verified: false, targetDigest: null, attestations: [], bitcoinAttestations: [], trustMode: 'structural-only', errors: ['Invalid OTS file format'] };
}
const targetDigestHex = bytesToHex(parsed.targetDigest);
@@ -1924,7 +1974,7 @@ export async function verifyOtsProof(otsBytes, expectedDigestHex) {
const actual = targetDigestHex.toLowerCase();
if (expected !== actual) {
errors.push(`Target digest mismatch: proof commits to ${actual.substring(0, 16)}... but expected ${expected.substring(0, 16)}...`);
return { verified: false, targetDigest: targetDigestHex, attestations: parsed.attestations, bitcoinAttestations: [], errors };
return { verified: false, targetDigest: targetDigestHex, attestations: parsed.attestations, bitcoinAttestations: [], trustMode: 'structural-only', errors };
}
}
@@ -1938,14 +1988,23 @@ export async function verifyOtsProof(otsBytes, expectedDigestHex) {
} else {
errors.push('Proof contains no Bitcoin attestations');
}
return { verified: false, targetDigest: targetDigestHex, attestations: parsed.attestations, bitcoinAttestations: [], errors };
return { verified: false, targetDigest: targetDigestHex, attestations: parsed.attestations, bitcoinAttestations: [], trustMode: 'structural-only', errors };
}
// 4. Verify each Bitcoin attestation against the actual Bitcoin block header
// G56-04: block headers are cross-checked across providers; trustMode is
// propagated from fetchBitcoinBlockHeader.
const verified = [];
let trustMode = 'structural-only';
for (const att of bitcoinAttestations) {
try {
const blockHeader = await fetchBitcoinBlockHeader(att.height);
// Track the strongest trust mode seen across all attestations
if (blockHeader.trustMode === 'multi-explorer-checked') {
trustMode = 'multi-explorer-checked';
} else if (blockHeader.trustMode === 'single-explorer-checked' && trustMode === 'structural-only') {
trustMode = 'single-explorer-checked';
}
// The attestation's digest is the merkle root (after walking the op tree).
// Bitcoin OTS stores the digest in reversed byte order (little-endian).
const computedMerkleRoot = bytesToHex(att.digest.slice().reverse());
@@ -1968,6 +2027,7 @@ export async function verifyOtsProof(otsBytes, expectedDigestHex) {
targetDigest: targetDigestHex,
attestations: parsed.attestations,
bitcoinAttestations: verified,
trustMode,
errors
};
}
+3 -3
View File
@@ -1,5 +1,5 @@
{
"VERSION": "v0.0.20",
"VERSION_NUMBER": "0.0.20",
"BUILD_DATE": "2026-07-24T13:42:06.579Z"
"VERSION": "v0.0.21",
"VERSION_NUMBER": "0.0.21",
"BUILD_DATE": "2026-07-24T14:15:01.028Z"
}
+36 -8
View File
@@ -10656,6 +10656,7 @@ var BITCOIN_API_PROVIDERS = [
{ name: "mempool", blockHash: (h) => `https://mempool.space/api/block-height/${h}`, block: (hash) => `https://mempool.space/api/block/${hash}` }
];
async function fetchBitcoinBlockHeader(height) {
const results = [];
for (const provider of BITCOIN_API_PROVIDERS) {
try {
const hashResp = await fetch(provider.blockHash(height), { headers: { "Accept": "text/plain" } });
@@ -10667,17 +10668,37 @@ async function fetchBitcoinBlockHeader(height) {
const blockData = await blockResp.json();
if (!blockData.merkle_root && !blockData.merkleroot) continue;
if (!blockData.timestamp && !blockData.time) continue;
return {
merkleroot: blockData.merkle_root || blockData.merkleroot,
results.push({
provider: provider.name,
merkleroot: (blockData.merkle_root || blockData.merkleroot).toLowerCase(),
time: blockData.timestamp || blockData.time,
height
};
});
} catch (e) {
console.warn(`[ots] Bitcoin API ${provider.name} failed for height ${height}:`, e.message);
continue;
}
}
throw new Error(`Could not fetch Bitcoin block header for height ${height} from any provider`);
if (results.length === 0) {
throw new Error(`Could not fetch Bitcoin block header for height ${height} from any provider`);
}
if (results.length > 1) {
const firstRoot = results[0].merkleroot;
for (let i = 1; i < results.length; i++) {
if (results[i].merkleroot !== firstRoot) {
const providerList = results.map((r) => `${r.provider}:${r.merkleroot.substring(0, 16)}...`).join(", ");
throw new Error(`Bitcoin providers disagree on merkle root for block ${height}: ${providerList}`);
}
}
}
const trustMode = results.length >= 2 ? "multi-explorer-checked" : "single-explorer-checked";
return {
merkleroot: results[0].merkleroot,
time: results[0].time,
height,
trustMode,
providers: results.map((r) => r.provider)
};
}
async function verifyOtsProof(otsBytes, expectedDigestHex) {
const errors = [];
@@ -10685,10 +10706,10 @@ async function verifyOtsProof(otsBytes, expectedDigestHex) {
try {
parsed = parseOtsFile(otsBytes);
} catch (e) {
return { verified: false, targetDigest: null, attestations: [], bitcoinAttestations: [], errors: [`Failed to parse OTS file: ${e.message}`] };
return { verified: false, targetDigest: null, attestations: [], bitcoinAttestations: [], trustMode: "structural-only", errors: [`Failed to parse OTS file: ${e.message}`] };
}
if (!parsed) {
return { verified: false, targetDigest: null, attestations: [], bitcoinAttestations: [], errors: ["Invalid OTS file format"] };
return { verified: false, targetDigest: null, attestations: [], bitcoinAttestations: [], trustMode: "structural-only", errors: ["Invalid OTS file format"] };
}
const targetDigestHex = bytesToHex3(parsed.targetDigest);
if (expectedDigestHex) {
@@ -10696,7 +10717,7 @@ async function verifyOtsProof(otsBytes, expectedDigestHex) {
const actual = targetDigestHex.toLowerCase();
if (expected !== actual) {
errors.push(`Target digest mismatch: proof commits to ${actual.substring(0, 16)}... but expected ${expected.substring(0, 16)}...`);
return { verified: false, targetDigest: targetDigestHex, attestations: parsed.attestations, bitcoinAttestations: [], errors };
return { verified: false, targetDigest: targetDigestHex, attestations: parsed.attestations, bitcoinAttestations: [], trustMode: "structural-only", errors };
}
}
const bitcoinAttestations = parsed.attestations.filter((a) => a.type === "bitcoin");
@@ -10707,12 +10728,18 @@ async function verifyOtsProof(otsBytes, expectedDigestHex) {
} else {
errors.push("Proof contains no Bitcoin attestations");
}
return { verified: false, targetDigest: targetDigestHex, attestations: parsed.attestations, bitcoinAttestations: [], errors };
return { verified: false, targetDigest: targetDigestHex, attestations: parsed.attestations, bitcoinAttestations: [], trustMode: "structural-only", errors };
}
const verified = [];
let trustMode = "structural-only";
for (const att of bitcoinAttestations) {
try {
const blockHeader = await fetchBitcoinBlockHeader(att.height);
if (blockHeader.trustMode === "multi-explorer-checked") {
trustMode = "multi-explorer-checked";
} else if (blockHeader.trustMode === "single-explorer-checked" && trustMode === "structural-only") {
trustMode = "single-explorer-checked";
}
const computedMerkleRoot = bytesToHex3(att.digest.slice().reverse());
if (computedMerkleRoot.toLowerCase() === blockHeader.merkleroot.toLowerCase()) {
verified.push({
@@ -10732,6 +10759,7 @@ async function verifyOtsProof(otsBytes, expectedDigestHex) {
targetDigest: targetDigestHex,
attestations: parsed.attestations,
bitcoinAttestations: verified,
trustMode,
errors
};
}
+13 -3
View File
@@ -640,8 +640,13 @@
if (result.verified && result.bitcoinAttestations.length > 0) {
const att = result.bitcoinAttestations[0];
const date = new Date(att.time * 1000).toISOString().substring(0, 19);
const trustLabel = result.trustMode === 'multi-explorer-checked'
? 'multi-explorer-checked (cross-verified)'
: result.trustMode === 'single-explorer-checked'
? 'single-explorer-checked (trusted API)'
: result.trustMode || 'unknown';
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}.`);
setOtsInfo(`OpenTimestamps proof verified. Bitcoin block ${att.height} (mined ${date} UTC). Merkle root matches. Trust mode: ${trustLabel} — block header is trusted from explorer API(s), not independently verified against PoW. 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');
@@ -853,11 +858,16 @@
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 publish a new kind 9999 event carrying the confirmed proof below; it will reference the original via an upgrade_of tag.`);
const trustLabel = verifyResult.trustMode === 'multi-explorer-checked'
? 'multi-explorer-checked (cross-verified)'
: verifyResult.trustMode === 'single-explorer-checked'
? 'single-explorer-checked (trusted API)'
: verifyResult.trustMode || 'unknown';
setStatus(otsUpgradeStatus, 'success', `Bitcoin attestation verified! Block ${att ? att.height : '?'} (mined ${date} UTC). Trust mode: ${trustLabel}. Proof upgraded (${currentOtsBytes.length} bytes). You can publish a new kind 9999 event carrying the confirmed proof below; it will reference the original via an upgrade_of tag.`);
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.`);
setOtsInfo(`OpenTimestamps proof verified. Bitcoin block ${att ? att.height : '?'}. Trust mode: ${trustLabel}. 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.`);
}