From e934826b0a6f4c175e88935351d4eb64db7e9c09 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:45:15 +0100 Subject: [PATCH 01/26] fix(bonanza): reconcile real chat HTML and timestamp buckets --- .../DarkPeers_BONanza_Giveaway.user.js | 116 ++++++++++++------ userscripts/giveaway/src/00-preamble.js | 5 +- userscripts/giveaway/src/06-lifecycle.js | 111 ++++++++++++----- 3 files changed, 162 insertions(+), 70 deletions(-) diff --git a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js index 863b14d..ea831d5 100644 --- a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js +++ b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js @@ -2,7 +2,7 @@ // @name DarkPeers BONanza Giveaway | Maghuro Fork // @namespace https://darkpeers.org/users/maghuro // @description BON giveaways on DarkPeers with an optional direct contribution to the BON Pool -// @version 1.5.15 +// @version 1.5.16 // @author 🤖 T.R.A.V.I.S., Maghuro & M.A.E.S.T.R.O. // @homepageURL https://darkpeers.org/users/maghuro // @updateURL https://gist.githubusercontent.com/maghuro/da2dbfec94951990cbc54e75a9aee318/raw/DarkPeers_BONanza_Giveaway.meta.js @@ -20,6 +20,9 @@ // DarkPeers-only fork of "Blutopia BON Giveaway" v6.2.2 by Nums (GPL-3.0-or-later). // Changes in this fork: +// - v1.5.16 reconciles real DarkPeers HTML/JoyPixels and second-resolution +// timestamps after acknowledged opening POSTs. Ambiguous/duplicate +// announcements remain blocked; sponsor boundary stays conservative. // - All non-DarkPeers tracker support, the upload.cx extra commands and the // openuserjs update check have been removed. // - A host-selected percentage (0-50%, steps of 5) of the final pot is @@ -4203,24 +4206,20 @@ body.host-panel-dragging * { // An HTTP timeout is NOT evidence the server rejected the POST. Never // release a pending opening snapshot merely because sendMessage returned // false; an announced giveaway may already exist publicly. + // UNIT3D returns HTML (with JoyPixels images) for BBCode chat announcements. + // Its second-resolution timestamps cannot fit inside a millisecond POST. async function verifyPendingGiveawayOpening(checkpoint) { if (!checkpoint || (checkpoint.status !== "pending" && checkpoint.status !== "confirmed") || !checkpoint.preparedMessage || !Number.isFinite(Number(checkpoint.startedAt))) { return null; } - const afterId = optionalFiniteNumber(checkpoint.afterMessageId); - // A missing pre-send cursor makes same-second identical announcements - // indistinguishable. Never auto-confirm such a snapshot or payout. if (!Number.isSafeInteger(afterId) || afterId <= 0) return null; const startedAt = Number(checkpoint.startedAt); const persistedHardEnd = optionalFiniteNumber(checkpoint.windowEndAt); const completedAt = optionalFiniteNumber(checkpoint.sendCompletedAt); const absoluteHardEnd = startedAt + OPENING_POST_MAX_PUBLICATION_WINDOW_MS; - // An absent completion time means the tab may have crashed mid-POST: - // the pre-persisted hard window is still authoritative. A recorded - // completion can only NARROW the window, never extend it. const windowEnd = Math.min( absoluteHardEnd, persistedHardEnd === null ? absoluteHardEnd : persistedHardEnd, @@ -4229,13 +4228,50 @@ body.host-panel-dragging * { if (!Number.isFinite(startedAt) || !Number.isFinite(windowEnd) || windowEnd <= startedAt) return null; const senderId = Math.floor(Number(OT_USER_ID)); - if (!Number.isFinite(senderId) || senderId <= 0) return null; + if (!Number.isSafeInteger(senderId) || senderId <= 0) return null; + const decodeEntities = value => String(value).replace( + /&(#x[0-9a-f]+|#\d+|amp|lt|gt|quot|apos|nbsp);/gi, + (entity, token) => { + const t = token.toLowerCase(); + if (t[0] === "#") { + const isHex = t[1] === "x"; + const n = Number.parseInt(t.slice(isHex ? 2 : 1), isHex ? 16 : 10); + return Number.isInteger(n) && n >= 0 && n <= 0x10ffff && + !(n >= 0xd800 && n <= 0xdfff) + ? String.fromCodePoint(n) : entity; + } + return ({amp: "&", lt: "<", gt: ">", quot: '"', + apos: "'", nbsp: " "})[t] || entity; + } + ); + const canonical = (value, rendered) => { + let content = String(value ?? ""); + if (rendered) { + content = content.replace(/]*>/gi, tag => { + const alt = tag.match(/\balt\s*=\s*(["'])([\s\S]*?)\1/i); + return alt ? alt[2] : "\uFFFD"; + }).replace(//gi, "\n") + .replace(/<\/(?:p|div)>/gi, "\n") + .replace(/<[^>]*>/g, ""); + } else { + content = content.replace( + /\[(?:\/)?(?:b|i|u|s|color(?:=[^\]]*)?|size(?:=[^\]]*)?|url(?:=[^\]]*)?)\]/gi, + "" + ); + } + return decodeEntities(content).replace(/[\s\u00a0]+/g, ""); + }; + const expected = canonical(checkpoint.preparedMessage, false); + if (!expected) return null; + const expectedHost = String(checkpoint.host || + (typeof getLoggedInUsername === "function" ? getLoggedInUsername() : "") + ).trim().replace(/^@/, "").toLowerCase(); for (let attempt = 0; attempt < 3; attempt++) { if (!ownsTabLock()) return null; try { - const url = new URL(`/api/chat/messages/${DARKPEERS_MAIN_CHATROOM_ID}`, location.origin); - if (afterId !== null) url.searchParams.set("after_id", String(afterId)); + const url = new URL("/api/chat/messages/" + DARKPEERS_MAIN_CHATROOM_ID, location.origin); + url.searchParams.set("after_id", String(afterId)); const response = await fetchWithTimeout( url, { credentials: "include", cache: "no-store" }, 5000 ); @@ -4244,46 +4280,53 @@ body.host-panel-dragging * { const payload = await response.json(); if (!ownsTabLock()) return null; if (!Array.isArray(payload?.data)) throw new Error("Malformed Main Chat messages"); - + const matches = []; for (const message of payload.data) { const id = optionalFiniteNumber(message?.id); if (!Number.isSafeInteger(id) || id <= afterId) continue; - // A confirmed checkpoint must identify the SAME original - // announcement after reload. Never replace it with a later - // identical post, even inside the bounded send window. if (checkpoint.status === "confirmed" && id !== optionalFiniteNumber(checkpoint.messageId)) continue; - const authorId = Math.floor(Number(message?.user_id ?? message?.user?.id)); - if (authorId !== senderId) continue; - if (String(message?.message ?? "") !== checkpoint.preparedMessage) continue; - + const knownAuthorIds = [ + message?.user_id, message?.user?.id, + message?.sender?.id, message?.author?.id + ].filter(v => v != null && v !== "").map(Number) + .filter(v => Number.isSafeInteger(v) && v > 0); + if (knownAuthorIds.length) { + if (knownAuthorIds.some(authorId => authorId !== senderId)) continue; + } else { + const serverName = String( + message?.user?.username ?? message?.user?.name ?? + message?.author?.username ?? message?.username ?? "" + ).trim().replace(/^@/, "").toLowerCase(); + if (!expectedHost || serverName !== expectedHost) continue; + } + if (canonical(message?.message, true) !== expected) continue; const rawTs = String(message?.created_at || ""); const timestamp = Date.parse(rawTs); const resolutionMs = unit3dTimestampResolutionMs(rawTs); - // Require the ENTIRE server timestamp bucket to be inside - // the original, bounded opening POST window. A bucket that - // overlaps the pre-send boundary is ambiguous and must not - // confirm an unrelated same-second message. An identical - // message sent after this attempt cannot confirm it either. if (!Number.isFinite(timestamp) || - !Number.isFinite(resolutionMs) || resolutionMs <= 0 || - timestamp < startedAt || - (timestamp + resolutionMs) > windowEnd) continue; - - return { + !Number.isFinite(resolutionMs) || resolutionMs <= 0) continue; + const whollyInside = + !(timestamp < startedAt || (timestamp + resolutionMs) > windowEnd); + const overlapsAttempt = + timestamp < windowEnd && timestamp + resolutionMs > startedAt; + // Only positive HTTP POST acknowledgement permits accepting + // a unique overlapping server-second bucket. On ambiguous + // delivery the stricter wholly-contained rule still applies. + if (!whollyInside && + !(checkpoint.sendAcknowledged === true && overlapsAttempt)) continue; + matches.push({ messageId: id, - // Exclude the entire potentially ambiguous publication - // timestamp bucket from sponsorship. The 45-minute - // deadline remains anchored to the earlier send start. sponsorStartTs: Math.max(startedAt, timestamp + resolutionMs) - }; + }); } + // Never guess between duplicate equal announcements. + if (matches.length === 1) return matches[0]; + if (matches.length > 1) return null; } catch (error) { console.warn("[BON Giveaway] Opening reconciliation deferred:", error); } - if (attempt < 2) { - await new Promise(resolve => setTimeout(resolve, 700)); - } + if (attempt < 2) await new Promise(resolve => setTimeout(resolve, 700)); } return null; } @@ -4646,6 +4689,8 @@ body.host-panel-dragging * { giveawayData.__sponsorAccountingVerified = false; giveawayData.openingCheckpoint = { status: "pending", + host: giveawayData.host, + sendAcknowledged: false, startedAt: openingSendStartedAt, afterMessageId, windowEndAt: openingSendStartedAt + OPENING_POST_MAX_PUBLICATION_WINDOW_MS, @@ -4670,6 +4715,7 @@ body.host-panel-dragging * { // this document survived the POST. If it did not, recovery uses // the previously persisted bounded upper limit instead. giveawayData.openingCheckpoint.sendCompletedAt = Date.now(); + giveawayData.openingCheckpoint.sendAcknowledged = introSent === true; if (!snapshotGiveaway({ force: true, verifyWrite: true })) { logEvent("Opening delivery checkpoint not durable", "Could not preserve the end of the opening attempt; recovery remains pending."); diff --git a/userscripts/giveaway/src/00-preamble.js b/userscripts/giveaway/src/00-preamble.js index 75f7149..ac26ae2 100644 --- a/userscripts/giveaway/src/00-preamble.js +++ b/userscripts/giveaway/src/00-preamble.js @@ -2,7 +2,7 @@ // @name DarkPeers BONanza Giveaway | Maghuro Fork // @namespace https://darkpeers.org/users/maghuro // @description BON giveaways on DarkPeers with an optional direct contribution to the BON Pool -// @version 1.5.15 +// @version 1.5.16 // @author 🤖 T.R.A.V.I.S., Maghuro & M.A.E.S.T.R.O. // @homepageURL https://darkpeers.org/users/maghuro // @updateURL https://gist.githubusercontent.com/maghuro/da2dbfec94951990cbc54e75a9aee318/raw/DarkPeers_BONanza_Giveaway.meta.js @@ -20,6 +20,9 @@ // DarkPeers-only fork of "Blutopia BON Giveaway" v6.2.2 by Nums (GPL-3.0-or-later). // Changes in this fork: +// - v1.5.16 reconciles real DarkPeers HTML/JoyPixels and second-resolution +// timestamps after acknowledged opening POSTs. Ambiguous/duplicate +// announcements remain blocked; sponsor boundary stays conservative. // - All non-DarkPeers tracker support, the upload.cx extra commands and the // openuserjs update check have been removed. // - A host-selected percentage (0-50%, steps of 5) of the final pot is diff --git a/userscripts/giveaway/src/06-lifecycle.js b/userscripts/giveaway/src/06-lifecycle.js index bf273f9..4a72a4d 100644 --- a/userscripts/giveaway/src/06-lifecycle.js +++ b/userscripts/giveaway/src/06-lifecycle.js @@ -10,24 +10,20 @@ // An HTTP timeout is NOT evidence the server rejected the POST. Never // release a pending opening snapshot merely because sendMessage returned // false; an announced giveaway may already exist publicly. + // UNIT3D returns HTML (with JoyPixels images) for BBCode chat announcements. + // Its second-resolution timestamps cannot fit inside a millisecond POST. async function verifyPendingGiveawayOpening(checkpoint) { if (!checkpoint || (checkpoint.status !== "pending" && checkpoint.status !== "confirmed") || !checkpoint.preparedMessage || !Number.isFinite(Number(checkpoint.startedAt))) { return null; } - const afterId = optionalFiniteNumber(checkpoint.afterMessageId); - // A missing pre-send cursor makes same-second identical announcements - // indistinguishable. Never auto-confirm such a snapshot or payout. if (!Number.isSafeInteger(afterId) || afterId <= 0) return null; const startedAt = Number(checkpoint.startedAt); const persistedHardEnd = optionalFiniteNumber(checkpoint.windowEndAt); const completedAt = optionalFiniteNumber(checkpoint.sendCompletedAt); const absoluteHardEnd = startedAt + OPENING_POST_MAX_PUBLICATION_WINDOW_MS; - // An absent completion time means the tab may have crashed mid-POST: - // the pre-persisted hard window is still authoritative. A recorded - // completion can only NARROW the window, never extend it. const windowEnd = Math.min( absoluteHardEnd, persistedHardEnd === null ? absoluteHardEnd : persistedHardEnd, @@ -36,13 +32,50 @@ if (!Number.isFinite(startedAt) || !Number.isFinite(windowEnd) || windowEnd <= startedAt) return null; const senderId = Math.floor(Number(OT_USER_ID)); - if (!Number.isFinite(senderId) || senderId <= 0) return null; + if (!Number.isSafeInteger(senderId) || senderId <= 0) return null; + const decodeEntities = value => String(value).replace( + /&(#x[0-9a-f]+|#\d+|amp|lt|gt|quot|apos|nbsp);/gi, + (entity, token) => { + const t = token.toLowerCase(); + if (t[0] === "#") { + const isHex = t[1] === "x"; + const n = Number.parseInt(t.slice(isHex ? 2 : 1), isHex ? 16 : 10); + return Number.isInteger(n) && n >= 0 && n <= 0x10ffff && + !(n >= 0xd800 && n <= 0xdfff) + ? String.fromCodePoint(n) : entity; + } + return ({amp: "&", lt: "<", gt: ">", quot: '"', + apos: "'", nbsp: " "})[t] || entity; + } + ); + const canonical = (value, rendered) => { + let content = String(value ?? ""); + if (rendered) { + content = content.replace(/]*>/gi, tag => { + const alt = tag.match(/\balt\s*=\s*(["'])([\s\S]*?)\1/i); + return alt ? alt[2] : "\uFFFD"; + }).replace(//gi, "\n") + .replace(/<\/(?:p|div)>/gi, "\n") + .replace(/<[^>]*>/g, ""); + } else { + content = content.replace( + /\[(?:\/)?(?:b|i|u|s|color(?:=[^\]]*)?|size(?:=[^\]]*)?|url(?:=[^\]]*)?)\]/gi, + "" + ); + } + return decodeEntities(content).replace(/[\s\u00a0]+/g, ""); + }; + const expected = canonical(checkpoint.preparedMessage, false); + if (!expected) return null; + const expectedHost = String(checkpoint.host || + (typeof getLoggedInUsername === "function" ? getLoggedInUsername() : "") + ).trim().replace(/^@/, "").toLowerCase(); for (let attempt = 0; attempt < 3; attempt++) { if (!ownsTabLock()) return null; try { - const url = new URL(`/api/chat/messages/${DARKPEERS_MAIN_CHATROOM_ID}`, location.origin); - if (afterId !== null) url.searchParams.set("after_id", String(afterId)); + const url = new URL("/api/chat/messages/" + DARKPEERS_MAIN_CHATROOM_ID, location.origin); + url.searchParams.set("after_id", String(afterId)); const response = await fetchWithTimeout( url, { credentials: "include", cache: "no-store" }, 5000 ); @@ -51,46 +84,53 @@ const payload = await response.json(); if (!ownsTabLock()) return null; if (!Array.isArray(payload?.data)) throw new Error("Malformed Main Chat messages"); - + const matches = []; for (const message of payload.data) { const id = optionalFiniteNumber(message?.id); if (!Number.isSafeInteger(id) || id <= afterId) continue; - // A confirmed checkpoint must identify the SAME original - // announcement after reload. Never replace it with a later - // identical post, even inside the bounded send window. if (checkpoint.status === "confirmed" && id !== optionalFiniteNumber(checkpoint.messageId)) continue; - const authorId = Math.floor(Number(message?.user_id ?? message?.user?.id)); - if (authorId !== senderId) continue; - if (String(message?.message ?? "") !== checkpoint.preparedMessage) continue; - + const knownAuthorIds = [ + message?.user_id, message?.user?.id, + message?.sender?.id, message?.author?.id + ].filter(v => v != null && v !== "").map(Number) + .filter(v => Number.isSafeInteger(v) && v > 0); + if (knownAuthorIds.length) { + if (knownAuthorIds.some(authorId => authorId !== senderId)) continue; + } else { + const serverName = String( + message?.user?.username ?? message?.user?.name ?? + message?.author?.username ?? message?.username ?? "" + ).trim().replace(/^@/, "").toLowerCase(); + if (!expectedHost || serverName !== expectedHost) continue; + } + if (canonical(message?.message, true) !== expected) continue; const rawTs = String(message?.created_at || ""); const timestamp = Date.parse(rawTs); const resolutionMs = unit3dTimestampResolutionMs(rawTs); - // Require the ENTIRE server timestamp bucket to be inside - // the original, bounded opening POST window. A bucket that - // overlaps the pre-send boundary is ambiguous and must not - // confirm an unrelated same-second message. An identical - // message sent after this attempt cannot confirm it either. if (!Number.isFinite(timestamp) || - !Number.isFinite(resolutionMs) || resolutionMs <= 0 || - timestamp < startedAt || - (timestamp + resolutionMs) > windowEnd) continue; - - return { + !Number.isFinite(resolutionMs) || resolutionMs <= 0) continue; + const whollyInside = + !(timestamp < startedAt || (timestamp + resolutionMs) > windowEnd); + const overlapsAttempt = + timestamp < windowEnd && timestamp + resolutionMs > startedAt; + // Only positive HTTP POST acknowledgement permits accepting + // a unique overlapping server-second bucket. On ambiguous + // delivery the stricter wholly-contained rule still applies. + if (!whollyInside && + !(checkpoint.sendAcknowledged === true && overlapsAttempt)) continue; + matches.push({ messageId: id, - // Exclude the entire potentially ambiguous publication - // timestamp bucket from sponsorship. The 45-minute - // deadline remains anchored to the earlier send start. sponsorStartTs: Math.max(startedAt, timestamp + resolutionMs) - }; + }); } + // Never guess between duplicate equal announcements. + if (matches.length === 1) return matches[0]; + if (matches.length > 1) return null; } catch (error) { console.warn("[BON Giveaway] Opening reconciliation deferred:", error); } - if (attempt < 2) { - await new Promise(resolve => setTimeout(resolve, 700)); - } + if (attempt < 2) await new Promise(resolve => setTimeout(resolve, 700)); } return null; } @@ -453,6 +493,8 @@ giveawayData.__sponsorAccountingVerified = false; giveawayData.openingCheckpoint = { status: "pending", + host: giveawayData.host, + sendAcknowledged: false, startedAt: openingSendStartedAt, afterMessageId, windowEndAt: openingSendStartedAt + OPENING_POST_MAX_PUBLICATION_WINDOW_MS, @@ -477,6 +519,7 @@ // this document survived the POST. If it did not, recovery uses // the previously persisted bounded upper limit instead. giveawayData.openingCheckpoint.sendCompletedAt = Date.now(); + giveawayData.openingCheckpoint.sendAcknowledged = introSent === true; if (!snapshotGiveaway({ force: true, verifyWrite: true })) { logEvent("Opening delivery checkpoint not durable", "Could not preserve the end of the opening attempt; recovery remains pending."); From 5b73906438967fb7fc2a2c22cebcc2d203073e65 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:45:47 +0100 Subject: [PATCH 02/26] test(bonanza): reproduce live DarkPeers opening failure and fail-closed cases --- tests/bonanza-opening-real-api.test.mjs | 111 ++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 tests/bonanza-opening-real-api.test.mjs diff --git a/tests/bonanza-opening-real-api.test.mjs b/tests/bonanza-opening-real-api.test.mjs new file mode 100644 index 0000000..f52ff3c --- /dev/null +++ b/tests/bonanza-opening-real-api.test.mjs @@ -0,0 +1,111 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import vm from "node:vm"; + +const lifecycle = readFileSync( + new URL("../userscripts/giveaway/src/06-lifecycle.js", import.meta.url), "utf8" +); +const first = lifecycle.indexOf(" async function verifyPendingGiveawayOpening("); +const last = lifecycle.indexOf("\n async function startGiveaway()", first); +assert.ok(first !== -1 && last > first); +const source = lifecycle.slice(first, last); +const startMs = Date.parse("2026-10-14T20:25:29.220Z"); +const endMs = Date.parse("2026-10-14T20:25:29.403Z"); +const preparedMessage = "🎁[b][i][u]\u2063[/u][/i][/b] 💙 [b][color=#4FAFFF]BON POOL CONTRIBUTION G\u200bIVEAWAY[/color][/b]\nI am hosting a giveaway for [b]101 042 BON[/b]"; +const renderedMessage = '🎁 \u2063 💙 BON POOL CONTRIBUTION G\u200bIVEAWAY
I am hosting a giveaway for 101 042 BON'; + +const real = (id = 629180, override = {}) => ({ + id, user: { id: 10, username: "maghuro" }, + message: renderedMessage, + created_at: "2026-10-14T20:25:29+00:00", + ...override +}); + +async function verify(records, opts = {}) { + const checkpoint = { + status: opts.status || "pending", host: "maghuro", + startedAt: startMs, sendCompletedAt: endMs, + windowEndAt: startMs + 10000, afterMessageId: 629175, + preparedMessage, sendAcknowledged: opts.ack !== false, + ...(opts.checkpoint || {}) + }; + const ctx = vm.createContext({ + URL, location: { origin: "https://darkpeers.org" }, + OT_USER_ID: 10, DARKPEERS_MAIN_CHATROOM_ID: 1, + OPENING_POST_MAX_PUBLICATION_WINDOW_MS: 10000, + ownsTabLock: () => true, + optionalFiniteNumber: v => v == null ? null : Number(v), + unit3dTimestampResolutionMs: str => /\.\d+/.test(String(str)) ? 1 : 1000, + fetchWithTimeout: async () => ({ ok: true, json: async () => ({ data: records }) }), + setTimeout: fn => fn(), console: { warn() {} } + }); + vm.runInContext(source, ctx); + return ctx.verifyPendingGiveawayOpening(checkpoint); +} + +test("real DarkPeers HTML, JoyPixels alt and whole-second timestamp confirm acknowledged POST", async () => { + const r = await verify([real()]); + assert.equal(r.messageId, 629180); + assert.equal(r.sponsorStartTs, Date.parse("2026-10-14T20:25:30Z")); +}); + +test("unacknowledged POST cannot use a partially overlapping server-second bucket", async () => { + assert.equal(await verify([real()], { ack: false }), null); +}); + +test("earlier identical chat announcement cannot confirm a later attempt", async () => { + assert.equal(await verify([real(629177, { + created_at: "2026-10-14T20:25:23+00:00" + })]), null); +}); + +test("duplicate same-second announcements remain unverified despite HTTP 200", async () => { + assert.equal(await verify([real(), real(629181)]), null); +}); + +test("author ID, before-send cursor and actual content are mandatory", async () => { + assert.equal(await verify([ + real(629170), + real(629181, { user: { id: 11, username: "other" } }), + real(629182, { message: renderedMessage.replace("101 042", "101 043") }) + ]), null); +}); + +test("conflicting numeric user ID defeats matching username", async () => { + assert.equal(await verify([real(629180, { user_id: 99 })]), null); +}); + +test("no numeric or username author evidence cannot confirm an opening", async () => { + assert.equal(await verify([real(629180, { user: undefined })]), null); +}); + +test("authenticated username fallback supports missing numeric user IDs", async () => { + assert.equal((await verify([ + real(629180, { user: { username: "maghuro" } }) + ])).messageId, 629180); +}); + +test("confirmed checkpoints are pinned to the saved message ID", async () => { + assert.equal(await verify([real()], { + status: "confirmed", checkpoint: { messageId: 629181 } + }), null); +}); + +test("publication after HTTP completion cannot confirm", async () => { + assert.equal(await verify([real(629182, { + created_at: "2026-10-14T20:25:30+00:00" + })]), null); +}); + +test("opening acknowledgement is durably saved before reconciliation", () => { + const post = lifecycle.indexOf("const introSent = await sendMessage("); + const ack = lifecycle.indexOf( + "giveawayData.openingCheckpoint.sendAcknowledged = introSent === true;", post + ); + const persist = lifecycle.indexOf( + "snapshotGiveaway({ force: true, verifyWrite: true })", ack + ); + const verified = lifecycle.indexOf("await verifyPendingGiveawayOpening(", ack); + assert.ok(post > 0 && ack > post && persist > ack && verified > persist); +}); From 8fdfef97cd4e00fcfe2da05b00b9a57e491f3fe8 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:47:08 +0100 Subject: [PATCH 03/26] test(bonanza): align hardening version assertion with 1.5.16 --- tests/bonanza-review-hardening.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/bonanza-review-hardening.test.mjs b/tests/bonanza-review-hardening.test.mjs index 1a646bf..823e276 100644 --- a/tests/bonanza-review-hardening.test.mjs +++ b/tests/bonanza-review-hardening.test.mjs @@ -9,7 +9,7 @@ const sourcePath = new URL( const source = readFileSync(sourcePath, "utf8"); test("review hardening invariants stay present", () => { - assert.match(source, /^\/\/ @version\s+1\.5\.15$/m); + assert.match(source, /^\/\/ @version\s+1\.5\.16$/m); assert.doesNotMatch(source, /pollChatFallback/); assert.doesNotMatch(source, /onlyguardians/i); assert.match(source, /async function getLatestMainChatReplayBoundary\(\)/); From 1f1fca177986839a8beafe84a24039c7d5f5add9 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:09:37 +0100 Subject: [PATCH 04/26] fix(bonanza): require per-attempt invisible proof to confirm coarse timestamp --- userscripts/giveaway/src/06-lifecycle.js | 36 ++++++++++++++++++++---- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/userscripts/giveaway/src/06-lifecycle.js b/userscripts/giveaway/src/06-lifecycle.js index 4a72a4d..c53216a 100644 --- a/userscripts/giveaway/src/06-lifecycle.js +++ b/userscripts/giveaway/src/06-lifecycle.js @@ -6,6 +6,20 @@ // sent minutes/hours later as this giveaway's opening. const OPENING_POST_MAX_PUBLICATION_WINDOW_MS = 10000; + // Unique live-opening proof encoded as non-rendering Unicode code points + // already observed surviving DarkPeers chat HTML and bridge markers. + function createOpeningProofMarker() { + if (!window.crypto || typeof window.crypto.getRandomValues !== "function") return null; + const bytes = new Uint8Array(16); + window.crypto.getRandomValues(bytes); + const bits = Array.from(bytes, b => + Array.from({ length: 8 }, (_, i) => + (b & (1 << (7 - i))) ? "\u200B" : "\u2063" + ).join("") + ).join(""); + return "\u2063\u200B\u2063\u200B" + bits + "\u200B\u2063\u200B\u2063"; + } + // Reconcile an opening announcement against authoritative Main Chat history. // An HTTP timeout is NOT evidence the server rejected the POST. Never // release a pending opening snapshot merely because sendMessage returned @@ -65,6 +79,10 @@ } return decodeEntities(content).replace(/[\s\u00a0]+/g, ""); }; + const proof = checkpoint.openingProof; + const hasUniqueProof = typeof proof === "string" && + /^\u2063\u200B\u2063\u200B[\u2063\u200B]{128}\u200B\u2063\u200B\u2063$/.test(proof) && + checkpoint.preparedMessage.endsWith(proof); const expected = canonical(checkpoint.preparedMessage, false); if (!expected) return null; const expectedHost = String(checkpoint.host || @@ -114,11 +132,12 @@ !(timestamp < startedAt || (timestamp + resolutionMs) > windowEnd); const overlapsAttempt = timestamp < windowEnd && timestamp + resolutionMs > startedAt; - // Only positive HTTP POST acknowledgement permits accepting - // a unique overlapping server-second bucket. On ambiguous - // delivery the stricter wholly-contained rule still applies. + // A positive HTTP status alone does not identify a returned + // same-second announcement. Require a unique random marker + // persisted before POST. Legacy openings remain fail-closed. if (!whollyInside && - !(checkpoint.sendAcknowledged === true && overlapsAttempt)) continue; + !(hasUniqueProof && overlapsAttempt && + canonical(message?.message, true).endsWith(proof))) continue; matches.push({ messageId: id, sponsorStartTs: Math.max(startedAt, timestamp + resolutionMs) @@ -486,7 +505,13 @@ // Deadline: conservative PRE-SEND time. Sponsorship: independently // verified PUBLICATION time (never the earlier request timestamp). const openingSendStartedAt = Date.now(); - const preparedOpening = prepareOutgoingMessage(introMessage); + const openingProof = REHEARSAL_MODE ? null : createOpeningProofMarker(); + if (!REHEARSAL_MODE && !openingProof) { + window.alert("GIVEAWAY ERROR: Secure opening proof unavailable. No message was posted."); + resetGiveaway(); + return; + } + const preparedOpening = prepareOutgoingMessage(introMessage) + (openingProof || ""); giveawayStartTime = new Date(openingSendStartedAt); giveawayData.openedAt = openingSendStartedAt; giveawayData.endTs = openingSendStartedAt + totalTimeMs; @@ -494,6 +519,7 @@ giveawayData.openingCheckpoint = { status: "pending", host: giveawayData.host, + openingProof, sendAcknowledged: false, startedAt: openingSendStartedAt, afterMessageId, From 3bc07bc20ee6698e74303b1d3a21a006696f2951 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:10:10 +0100 Subject: [PATCH 05/26] build(bonanza): sync generated script with unique proof --- .../DarkPeers_BONanza_Giveaway.user.js | 36 ++++++++++++++++--- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js index ea831d5..279d971 100644 --- a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js +++ b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js @@ -4202,6 +4202,20 @@ body.host-panel-dragging * { // sent minutes/hours later as this giveaway's opening. const OPENING_POST_MAX_PUBLICATION_WINDOW_MS = 10000; + // Unique live-opening proof encoded as non-rendering Unicode code points + // already observed surviving DarkPeers chat HTML and bridge markers. + function createOpeningProofMarker() { + if (!window.crypto || typeof window.crypto.getRandomValues !== "function") return null; + const bytes = new Uint8Array(16); + window.crypto.getRandomValues(bytes); + const bits = Array.from(bytes, b => + Array.from({ length: 8 }, (_, i) => + (b & (1 << (7 - i))) ? "\u200B" : "\u2063" + ).join("") + ).join(""); + return "\u2063\u200B\u2063\u200B" + bits + "\u200B\u2063\u200B\u2063"; + } + // Reconcile an opening announcement against authoritative Main Chat history. // An HTTP timeout is NOT evidence the server rejected the POST. Never // release a pending opening snapshot merely because sendMessage returned @@ -4261,6 +4275,10 @@ body.host-panel-dragging * { } return decodeEntities(content).replace(/[\s\u00a0]+/g, ""); }; + const proof = checkpoint.openingProof; + const hasUniqueProof = typeof proof === "string" && + /^\u2063\u200B\u2063\u200B[\u2063\u200B]{128}\u200B\u2063\u200B\u2063$/.test(proof) && + checkpoint.preparedMessage.endsWith(proof); const expected = canonical(checkpoint.preparedMessage, false); if (!expected) return null; const expectedHost = String(checkpoint.host || @@ -4310,11 +4328,12 @@ body.host-panel-dragging * { !(timestamp < startedAt || (timestamp + resolutionMs) > windowEnd); const overlapsAttempt = timestamp < windowEnd && timestamp + resolutionMs > startedAt; - // Only positive HTTP POST acknowledgement permits accepting - // a unique overlapping server-second bucket. On ambiguous - // delivery the stricter wholly-contained rule still applies. + // A positive HTTP status alone does not identify a returned + // same-second announcement. Require a unique random marker + // persisted before POST. Legacy openings remain fail-closed. if (!whollyInside && - !(checkpoint.sendAcknowledged === true && overlapsAttempt)) continue; + !(hasUniqueProof && overlapsAttempt && + canonical(message?.message, true).endsWith(proof))) continue; matches.push({ messageId: id, sponsorStartTs: Math.max(startedAt, timestamp + resolutionMs) @@ -4682,7 +4701,13 @@ body.host-panel-dragging * { // Deadline: conservative PRE-SEND time. Sponsorship: independently // verified PUBLICATION time (never the earlier request timestamp). const openingSendStartedAt = Date.now(); - const preparedOpening = prepareOutgoingMessage(introMessage); + const openingProof = REHEARSAL_MODE ? null : createOpeningProofMarker(); + if (!REHEARSAL_MODE && !openingProof) { + window.alert("GIVEAWAY ERROR: Secure opening proof unavailable. No message was posted."); + resetGiveaway(); + return; + } + const preparedOpening = prepareOutgoingMessage(introMessage) + (openingProof || ""); giveawayStartTime = new Date(openingSendStartedAt); giveawayData.openedAt = openingSendStartedAt; giveawayData.endTs = openingSendStartedAt + totalTimeMs; @@ -4690,6 +4715,7 @@ body.host-panel-dragging * { giveawayData.openingCheckpoint = { status: "pending", host: giveawayData.host, + openingProof, sendAcknowledged: false, startedAt: openingSendStartedAt, afterMessageId, From 11749063611b3e97b4b91921ce0a5de7432dcc9d Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:10:13 +0100 Subject: [PATCH 06/26] test(bonanza): cover random opening proof and uncertain POST recovery --- tests/bonanza-opening-real-api.test.mjs | 31 +++++++++++++++++++++---- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/tests/bonanza-opening-real-api.test.mjs b/tests/bonanza-opening-real-api.test.mjs index f52ff3c..4e9e38d 100644 --- a/tests/bonanza-opening-real-api.test.mjs +++ b/tests/bonanza-opening-real-api.test.mjs @@ -12,8 +12,10 @@ assert.ok(first !== -1 && last > first); const source = lifecycle.slice(first, last); const startMs = Date.parse("2026-10-14T20:25:29.220Z"); const endMs = Date.parse("2026-10-14T20:25:29.403Z"); -const preparedMessage = "🎁[b][i][u]\u2063[/u][/i][/b] 💙 [b][color=#4FAFFF]BON POOL CONTRIBUTION G\u200bIVEAWAY[/color][/b]\nI am hosting a giveaway for [b]101 042 BON[/b]"; -const renderedMessage = '🎁 \u2063 💙 BON POOL CONTRIBUTION G\u200bIVEAWAY
I am hosting a giveaway for 101 042 BON'; +const openingProof = "\u2063\u200B\u2063\u200B" + + "\u200B\u2063".repeat(64) + "\u200B\u2063\u200B\u2063"; +const preparedMessage = "🎁[b][i][u]\u2063[/u][/i][/b] 💙 [b][color=#4FAFFF]BON POOL CONTRIBUTION G\u200bIVEAWAY[/color][/b]\nI am hosting a giveaway for [b]101 042 BON[/b]" + openingProof; +const renderedMessage = '🎁 \u2063 💙 BON POOL CONTRIBUTION G\u200bIVEAWAY
I am hosting a giveaway for 101 042 BON' + openingProof; const real = (id = 629180, override = {}) => ({ id, user: { id: 10, username: "maghuro" }, @@ -27,7 +29,7 @@ async function verify(records, opts = {}) { status: opts.status || "pending", host: "maghuro", startedAt: startMs, sendCompletedAt: endMs, windowEndAt: startMs + 10000, afterMessageId: 629175, - preparedMessage, sendAcknowledged: opts.ack !== false, + preparedMessage, openingProof, sendAcknowledged: opts.ack !== false, ...(opts.checkpoint || {}) }; const ctx = vm.createContext({ @@ -50,8 +52,27 @@ test("real DarkPeers HTML, JoyPixels alt and whole-second timestamp confirm ackn assert.equal(r.sponsorStartTs, Date.parse("2026-10-14T20:25:30Z")); }); -test("unacknowledged POST cannot use a partially overlapping server-second bucket", async () => { - assert.equal(await verify([real()], { ack: false }), null); +test("durable unique proof recovers a posted opening even after lost HTTP acknowledgement", async () => { + assert.equal((await verify([real()], { ack: false })).messageId, 629180); +}); + +test("old checkpoints without an opening proof still cannot match partial server-second buckets", async () => { + assert.equal(await verify([real()], { checkpoint: { openingProof: null } }), null); +}); + +test("mismatched or copied opening proof fails despite matching text", async () => { + const differentMarker = "\u2063\u200B\u2063\u200B" + + "\u2063\u200B".repeat(64) + "\u200B\u2063\u200B\u2063"; + assert.equal(await verify([real(629180, { + message: renderedMessage.replace(openingProof, differentMarker) + })]), null); +}); + +test("the opening path generates a fresh 128-bit proof before checkpoint and POST", () => { + const marker = lifecycle.indexOf("const openingProof = REHEARSAL_MODE ? null : createOpeningProofMarker()"); + const saved = lifecycle.indexOf("openingProof,", marker); + const posted = lifecycle.indexOf("const introSent = await sendMessage(", marker); + assert.ok(marker > 0 && saved > marker && posted > saved); }); test("earlier identical chat announcement cannot confirm a later attempt", async () => { From 123871799eda349454881d93805c9dfebc6f7fa7 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:12:45 +0100 Subject: [PATCH 07/26] fix(bonanza): show safe non-modal cancellation for pending openings --- userscripts/giveaway/src/05-initialization.js | 105 ++++++++++++++++-- 1 file changed, 96 insertions(+), 9 deletions(-) diff --git a/userscripts/giveaway/src/05-initialization.js b/userscripts/giveaway/src/05-initialization.js index 43cca3a..a3ca1bc 100644 --- a/userscripts/giveaway/src/05-initialization.js +++ b/userscripts/giveaway/src/05-initialization.js @@ -1762,6 +1762,100 @@ sponsorRecoveryInterval = setInterval(retry, SPONSOR_GIFT_HISTORY_POLL_MS); } + // An unconfirmed opening must never trap the host in repeated modal alerts. + // Expose an explicit, guarded archive/cancel action instead. A cancelled + // opening is never settled automatically, and sponsors must be checked by + // the host against authenticated Gift History. + function showUnverifiedOpeningControls(savedSnapshot) { + const checkpoint = savedSnapshot?.giveawayData?.openingCheckpoint; + if (!checkpoint || document.getElementById("bonanzaOpeningRecoveryControls")) return; + + const node = document.createElement("aside"); + node.id = "bonanzaOpeningRecoveryControls"; + node.setAttribute("role", "status"); + Object.assign(node.style, { + position: "fixed", top: "76px", right: "16px", + maxWidth: "390px", zIndex: "2147483646", + background: "#1c2730", color: "#fff", + padding: "14px", border: "2px solid #ffbd52", + borderRadius: "9px", boxShadow: "0 3px 18px #0008" + }); + const msg = document.createElement("p"); + msg.textContent = "BONanza opening unverified. No entries, settlement or BON transfers will resume. Check the public chat and Gift History before cancelling."; + node.appendChild(msg); + + if (checkpoint.status === "pending") { + const button = document.createElement("button"); + button.type = "button"; + button.textContent = "Cancel & archive pending giveaway"; + button.style.cssText = "padding:8px;cursor:pointer;border-radius:5px;"; + button.addEventListener("click", async () => { + if (!window.confirm( + "Cancel this pending opening? This only archives local recovery state. " + + "It does NOT remove the public announcement or refund sponsor gifts." + )) return; + if (!await acquireTabLock()) { + window.alert("Cannot cancel while another tab owns the giveaway. Close other DarkPeers tabs."); + return; + } + + let cancelled = false; + try { + const host = getLoggedInUsername(); + const current = readActiveGiveawayRawForHost(host, { migrateLegacy: true }); + if (!ownsTabLock() || !current.readOk || !current.hasStoredState || + !current.raw || !current.storageKey) { + throw new Error("Saved recovery state is missing or cannot be read safely."); + } + const saved = JSON.parse(current.raw); + const data = saved?.giveawayData; + const proof = data?.openingCheckpoint; + if (!data || normalizeUserKey(data.host) !== normalizeUserKey(host) || + (data.rehearsalMode === true) !== REHEARSAL_MODE || + proof?.status !== "pending" || + Number(proof.startedAt) !== Number(checkpoint.startedAt)) { + throw new Error("Saved recovery state has changed; cancellation stopped."); + } + if (data.settlement || data.closingIntent || data.closingNoticeSent || + data.closingNoticeProgress) { + throw new Error("Settlement evidence exists. Manual audit is required."); + } + + const archiveKey = [ + "bonanza-giveaway-cancelled", location.hostname, + encodeURIComponent(normalizeUserKey(host)), + String(proof.startedAt) + ].join("::"); + const existing = localStorage.getItem(archiveKey); + if (existing !== null && existing !== current.raw) { + throw new Error("A different archive already exists for this opening."); + } + if (existing === null) localStorage.setItem(archiveKey, current.raw); + if (localStorage.getItem(archiveKey) !== current.raw) { + throw new Error("Archive write verification failed. Active state retained."); + } + if (localStorage.getItem(current.storageKey) !== current.raw) { + throw new Error("Active state changed before cancellation. No deletion performed."); + } + localStorage.removeItem(current.storageKey); + if (localStorage.getItem(current.storageKey) !== null) { + throw new Error("Active snapshot deletion failed."); + } + cancelled = true; + console.info("[BON Giveaway] Pending opening archived (not settled):", archiveKey); + } catch (error) { + console.error("[BON Giveaway] Pending cancellation deferred:", error); + window.alert("Cancellation not completed: " + String(error?.message || error)); + } finally { + releaseTabLock(); + } + if (cancelled) window.location.reload(); + }); + node.appendChild(button); + } + document.body.appendChild(node); + } + async function restoreGiveawayFromSnapshot(snap) { // The earlier snapshot is only a discovery hint. Another tab may // persist a newer settlement while we await the Web Lock. Classify @@ -1888,10 +1982,7 @@ if (!verified) { logEvent("Opening recovery deferred", "Main Chat could not prove the uncertain opening; existing checkpoint preserved."); - window.alert( - "GIVEAWAY RECOVERY PAUSED: The public opening could not be verified. " + - "No giveaway settlement or BON transfer will run. Retry from Main Chat later." - ); + showUnverifiedOpeningControls(snap); releaseTabLock(); return false; } @@ -1902,11 +1993,7 @@ if (overdue > EXPIRED_SNAPSHOT_SETTLEMENT_GRACE_MS) { logEvent("Opening recovery requires manual review", "Opening verified but its automatic settlement grace has expired."); - window.alert( - "GIVEAWAY RECOVERY PAUSED: An old public opening was verified, " + - "but automatic settlement is unsafe after the recovery grace. " + - "Keep the saved recovery state for manual review." - ); + showUnverifiedOpeningControls(snap); releaseTabLock(); return false; } From 739ca66f6ff79c212996950a783c5c4fa1f8f803 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:13:29 +0100 Subject: [PATCH 08/26] docs/test/build(bonanza): cover recoverable proof and cancellation --- userscripts/giveaway/src/00-preamble.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/userscripts/giveaway/src/00-preamble.js b/userscripts/giveaway/src/00-preamble.js index ac26ae2..393ca45 100644 --- a/userscripts/giveaway/src/00-preamble.js +++ b/userscripts/giveaway/src/00-preamble.js @@ -21,8 +21,8 @@ // DarkPeers-only fork of "Blutopia BON Giveaway" v6.2.2 by Nums (GPL-3.0-or-later). // Changes in this fork: // - v1.5.16 reconciles real DarkPeers HTML/JoyPixels and second-resolution -// timestamps after acknowledged opening POSTs. Ambiguous/duplicate -// announcements remain blocked; sponsor boundary stays conservative. +// timestamps using per-opening 128-bit invisible proof markers. Ambiguous +// announcements stay blocked; failed openings offer guarded archive/cancel. // - All non-DarkPeers tracker support, the upload.cx extra commands and the // openuserjs update check have been removed. // - A host-selected percentage (0-50%, steps of 5) of the final pot is From eef1315294833aeb83a3af46d27ba7c8bc9fc8d5 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:13:33 +0100 Subject: [PATCH 09/26] docs/test/build(bonanza): cover recoverable proof and cancellation --- .../DarkPeers_BONanza_Giveaway.user.js | 109 ++++++++++++++++-- 1 file changed, 98 insertions(+), 11 deletions(-) diff --git a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js index 279d971..8a289f8 100644 --- a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js +++ b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js @@ -21,8 +21,8 @@ // DarkPeers-only fork of "Blutopia BON Giveaway" v6.2.2 by Nums (GPL-3.0-or-later). // Changes in this fork: // - v1.5.16 reconciles real DarkPeers HTML/JoyPixels and second-resolution -// timestamps after acknowledged opening POSTs. Ambiguous/duplicate -// announcements remain blocked; sponsor boundary stays conservative. +// timestamps using per-opening 128-bit invisible proof markers. Ambiguous +// announcements stay blocked; failed openings offer guarded archive/cancel. // - All non-DarkPeers tracker support, the upload.cx extra commands and the // openuserjs update check have been removed. // - A host-selected percentage (0-50%, steps of 5) of the final pot is @@ -3608,6 +3608,100 @@ body.host-panel-dragging * { sponsorRecoveryInterval = setInterval(retry, SPONSOR_GIFT_HISTORY_POLL_MS); } + // An unconfirmed opening must never trap the host in repeated modal alerts. + // Expose an explicit, guarded archive/cancel action instead. A cancelled + // opening is never settled automatically, and sponsors must be checked by + // the host against authenticated Gift History. + function showUnverifiedOpeningControls(savedSnapshot) { + const checkpoint = savedSnapshot?.giveawayData?.openingCheckpoint; + if (!checkpoint || document.getElementById("bonanzaOpeningRecoveryControls")) return; + + const node = document.createElement("aside"); + node.id = "bonanzaOpeningRecoveryControls"; + node.setAttribute("role", "status"); + Object.assign(node.style, { + position: "fixed", top: "76px", right: "16px", + maxWidth: "390px", zIndex: "2147483646", + background: "#1c2730", color: "#fff", + padding: "14px", border: "2px solid #ffbd52", + borderRadius: "9px", boxShadow: "0 3px 18px #0008" + }); + const msg = document.createElement("p"); + msg.textContent = "BONanza opening unverified. No entries, settlement or BON transfers will resume. Check the public chat and Gift History before cancelling."; + node.appendChild(msg); + + if (checkpoint.status === "pending") { + const button = document.createElement("button"); + button.type = "button"; + button.textContent = "Cancel & archive pending giveaway"; + button.style.cssText = "padding:8px;cursor:pointer;border-radius:5px;"; + button.addEventListener("click", async () => { + if (!window.confirm( + "Cancel this pending opening? This only archives local recovery state. " + + "It does NOT remove the public announcement or refund sponsor gifts." + )) return; + if (!await acquireTabLock()) { + window.alert("Cannot cancel while another tab owns the giveaway. Close other DarkPeers tabs."); + return; + } + + let cancelled = false; + try { + const host = getLoggedInUsername(); + const current = readActiveGiveawayRawForHost(host, { migrateLegacy: true }); + if (!ownsTabLock() || !current.readOk || !current.hasStoredState || + !current.raw || !current.storageKey) { + throw new Error("Saved recovery state is missing or cannot be read safely."); + } + const saved = JSON.parse(current.raw); + const data = saved?.giveawayData; + const proof = data?.openingCheckpoint; + if (!data || normalizeUserKey(data.host) !== normalizeUserKey(host) || + (data.rehearsalMode === true) !== REHEARSAL_MODE || + proof?.status !== "pending" || + Number(proof.startedAt) !== Number(checkpoint.startedAt)) { + throw new Error("Saved recovery state has changed; cancellation stopped."); + } + if (data.settlement || data.closingIntent || data.closingNoticeSent || + data.closingNoticeProgress) { + throw new Error("Settlement evidence exists. Manual audit is required."); + } + + const archiveKey = [ + "bonanza-giveaway-cancelled", location.hostname, + encodeURIComponent(normalizeUserKey(host)), + String(proof.startedAt) + ].join("::"); + const existing = localStorage.getItem(archiveKey); + if (existing !== null && existing !== current.raw) { + throw new Error("A different archive already exists for this opening."); + } + if (existing === null) localStorage.setItem(archiveKey, current.raw); + if (localStorage.getItem(archiveKey) !== current.raw) { + throw new Error("Archive write verification failed. Active state retained."); + } + if (localStorage.getItem(current.storageKey) !== current.raw) { + throw new Error("Active state changed before cancellation. No deletion performed."); + } + localStorage.removeItem(current.storageKey); + if (localStorage.getItem(current.storageKey) !== null) { + throw new Error("Active snapshot deletion failed."); + } + cancelled = true; + console.info("[BON Giveaway] Pending opening archived (not settled):", archiveKey); + } catch (error) { + console.error("[BON Giveaway] Pending cancellation deferred:", error); + window.alert("Cancellation not completed: " + String(error?.message || error)); + } finally { + releaseTabLock(); + } + if (cancelled) window.location.reload(); + }); + node.appendChild(button); + } + document.body.appendChild(node); + } + async function restoreGiveawayFromSnapshot(snap) { // The earlier snapshot is only a discovery hint. Another tab may // persist a newer settlement while we await the Web Lock. Classify @@ -3734,10 +3828,7 @@ body.host-panel-dragging * { if (!verified) { logEvent("Opening recovery deferred", "Main Chat could not prove the uncertain opening; existing checkpoint preserved."); - window.alert( - "GIVEAWAY RECOVERY PAUSED: The public opening could not be verified. " + - "No giveaway settlement or BON transfer will run. Retry from Main Chat later." - ); + showUnverifiedOpeningControls(snap); releaseTabLock(); return false; } @@ -3748,11 +3839,7 @@ body.host-panel-dragging * { if (overdue > EXPIRED_SNAPSHOT_SETTLEMENT_GRACE_MS) { logEvent("Opening recovery requires manual review", "Opening verified but its automatic settlement grace has expired."); - window.alert( - "GIVEAWAY RECOVERY PAUSED: An old public opening was verified, " + - "but automatic settlement is unsafe after the recovery grace. " + - "Keep the saved recovery state for manual review." - ); + showUnverifiedOpeningControls(snap); releaseTabLock(); return false; } From 4c0f708537725022bbf2165a9d2456b952622924 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:13:36 +0100 Subject: [PATCH 10/26] docs/test/build(bonanza): cover recoverable proof and cancellation --- tests/bonanza-opening-real-api.test.mjs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/bonanza-opening-real-api.test.mjs b/tests/bonanza-opening-real-api.test.mjs index 4e9e38d..bd5fb73 100644 --- a/tests/bonanza-opening-real-api.test.mjs +++ b/tests/bonanza-opening-real-api.test.mjs @@ -130,3 +130,20 @@ test("opening acknowledgement is durably saved before reconciliation", () => { const verified = lifecycle.indexOf("await verifyPendingGiveawayOpening(", ack); assert.ok(post > 0 && ack > post && persist > ack && verified > persist); }); + +test("pending opening offers a user-controlled non-modal archive, never automatic clearing", () => { + const initialization = readFileSync( + new URL("../userscripts/giveaway/src/05-initialization.js", import.meta.url), "utf8" + ); + assert.match(initialization, /function showUnverifiedOpeningControls\(/); + assert.match(initialization, /Cancel & archive pending giveaway/); + const start = initialization.indexOf("async function restoreGiveawayFromSnapshot("); + const end = initialization.indexOf("function showUnverifiedOpeningControls(snap)", start); + assert.ok(start >= 0); + const recovery = initialization.slice(start); + assert.match(recovery, /showUnverifiedOpeningControls\(snap\);/); + const createArchive = initialization.indexOf("localStorage.setItem(archiveKey, current.raw)"); + const verifyArchive = initialization.indexOf("localStorage.getItem(archiveKey) !== current.raw", createArchive); + const remove = initialization.indexOf("localStorage.removeItem(current.storageKey)", verifyArchive); + assert.ok(createArchive > 0 && verifyArchive > createArchive && remove > verifyArchive); +}); From 4a9a70f120e3f27ee604b37692eabdadfd4a4f3c Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:13:39 +0100 Subject: [PATCH 11/26] docs/test/build(bonanza): cover recoverable proof and cancellation --- docs/runbooks/bonanza-operations.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/runbooks/bonanza-operations.md b/docs/runbooks/bonanza-operations.md index f3c5a68..3468406 100644 --- a/docs/runbooks/bonanza-operations.md +++ b/docs/runbooks/bonanza-operations.md @@ -92,3 +92,23 @@ location.reload(); ``` Never run a real giveaway while the REHEARSAL MODE banner is visible. + +## Unverified opening in LIVE mode + +If Main Chat shows an announcement but it cannot be proved by the userscript, the +checkpoint stays pending and **no giveaway settlement, sponsor processing, or +BON transfer runs**. The recovery alert is now a non-modal notice. + +Use **Cancel & archive pending giveaway** only after checking the Main Chat and +canonical Gift History. It requires a manual confirmation and an exclusive tab +lock. The script re-reads the latest exact pending checkpoint, rejects any +settlement evidence, writes a verified original-state archive, and only then +removes the active key. It does **not** delete public chat messages or refund +gifts received from sponsors. Those remain a manual follow-up with donors. +If the archive cannot be persisted, the active snapshot remains untouched. + +New LIVE announcements carry a cryptographic invisible 128-bit per-opening +proof to disambiguate whole-second API timestamps, and older snapshots cannot +be silently upgraded to that proof. Rehearsal mode cannot establish that an +invisible proof survives the real Main Chat rendering pipeline; validate this +against the site before merging a new release. From 27a19dbd5acc4c484192a4ce3a0af00bf11acba9 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:14:53 +0100 Subject: [PATCH 12/26] docs(bonanza): encode proof-bound opening safety invariant --- AGENTS.md | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9a167cd..1d841d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,12 +53,19 @@ multiple userscripts, not just BONanza. identical old announcement might falsely verify the new opening). - Before posting the public giveaway opening, persist and read back a **pending opening checkpoint** with the exact prepared announcement, - pre-send timestamp, Main Chat cursor and a bounded publication window. - When the send resolves, persist its completion timestamp to further - narrow that window. Reconciliation must prove the **entire** server-side - timestamp-resolution bucket lies inside the original opening attempt: - same-second overlap or later identical announcements are NOT sufficient. - ANY opening API failure, including non-OK HTTP responses, must never + pre-send timestamp, Main Chat cursor, a cryptographically random + **per-opening proof marker** and a bounded publication window. + The proof marker must be part of the exact posted payload, survive real + DarkPeers HTML rendering, and be verified against the persisted checkpoint + with the authenticated author and after-send cursor. The server's timestamp + bucket may overlap the original send window ONLY when that distinct durable + per-opening marker matches uniquely. Without a valid marker, require the + **entire** server timestamp-resolution bucket to lie inside the original + opening attempt. An HTTP 200 alone is never sufficient to identify an + otherwise-identical same-second announcement. Once the send resolves, persist + its completion timestamp to narrow the window. A duplicated marker is + ambiguous and must not confirm. Old snapshots cannot acquire a marker + retroactively. ANY opening API failure, including non-OK HTTP responses, must never trigger the chatbox fallback: the server may already have published the opening, so reconcile the original checkpoint instead. A timeout/ownership loss is ambiguous: do not erase the checkpoint, From a382e32c81b7982d9a15f7ff7903e29693c9ad5e Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:16:28 +0100 Subject: [PATCH 13/26] fix(bonanza): reject unsupported custom BBCode before public opening --- userscripts/giveaway/src/06-lifecycle.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/userscripts/giveaway/src/06-lifecycle.js b/userscripts/giveaway/src/06-lifecycle.js index c53216a..f73568b 100644 --- a/userscripts/giveaway/src/06-lifecycle.js +++ b/userscripts/giveaway/src/06-lifecycle.js @@ -177,6 +177,20 @@ return; } + // DarkPeers renders richer BBCode (quote/code/img etc.) to HTML whose + // text cannot be compared 1:1 to the original markup. Reject those + // custom inputs BEFORE a LIVE public POST, never after announcing it. + const customStartText = String(customMessageInput?.value || ""); + const inlineTags = new Set(["b", "i", "u", "s", "color", "size", "url"]); + const unsupportedTag = [...customStartText.matchAll(/\[\/?([a-z][a-z0-9]*)(?:=[^\]]*)?\]/gi)] + .find(match => !inlineTags.has(match[1].toLowerCase())); + if (unsupportedTag || /<\/?[a-z][^>]*>/i.test(customStartText)) { + window.alert("GIVEAWAY ERROR: Custom message contains unsupported BBCode/HTML. " + + "Remove the markup before starting. Nothing was posted."); + customMessageInput?.focus(); + return; + } + // Never overwrite a recoverable opening whose public POST was ambiguous. // The pending snapshot is the only path to reconcile entrants/sponsors // after a reload. A second start could otherwise destroy that evidence. From 8892414f1a4561795105b566c0d0a643bc4547ba Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:16:50 +0100 Subject: [PATCH 14/26] build(bonanza): sync custom BBCode preflight --- .../giveaway/DarkPeers_BONanza_Giveaway.user.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js index 8a289f8..7a28447 100644 --- a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js +++ b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js @@ -4460,6 +4460,20 @@ body.host-panel-dragging * { return; } + // DarkPeers renders richer BBCode (quote/code/img etc.) to HTML whose + // text cannot be compared 1:1 to the original markup. Reject those + // custom inputs BEFORE a LIVE public POST, never after announcing it. + const customStartText = String(customMessageInput?.value || ""); + const inlineTags = new Set(["b", "i", "u", "s", "color", "size", "url"]); + const unsupportedTag = [...customStartText.matchAll(/\[\/?([a-z][a-z0-9]*)(?:=[^\]]*)?\]/gi)] + .find(match => !inlineTags.has(match[1].toLowerCase())); + if (unsupportedTag || /<\/?[a-z][^>]*>/i.test(customStartText)) { + window.alert("GIVEAWAY ERROR: Custom message contains unsupported BBCode/HTML. " + + "Remove the markup before starting. Nothing was posted."); + customMessageInput?.focus(); + return; + } + // Never overwrite a recoverable opening whose public POST was ambiguous. // The pending snapshot is the only path to reconcile entrants/sponsors // after a reload. A second start could otherwise destroy that evidence. From d70f2e8f38c7bcd4b01bc2167a3e3fe5a6f74621 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:16:54 +0100 Subject: [PATCH 15/26] test(bonanza): regression for unsupported custom BBCode --- tests/bonanza-opening-real-api.test.mjs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/bonanza-opening-real-api.test.mjs b/tests/bonanza-opening-real-api.test.mjs index bd5fb73..7100e8f 100644 --- a/tests/bonanza-opening-real-api.test.mjs +++ b/tests/bonanza-opening-real-api.test.mjs @@ -147,3 +147,14 @@ test("pending opening offers a user-controlled non-modal archive, never automati const remove = initialization.indexOf("localStorage.removeItem(current.storageKey)", verifyArchive); assert.ok(createArchive > 0 && verifyArchive > createArchive && remove > verifyArchive); }); + +test("unsupported custom BBCode is rejected before public POST", () => { + const beginning = lifecycle.indexOf("async function startGiveaway()"); + const block = lifecycle.indexOf("const unsupportedTag =", beginning); + const preflight = lifecycle.indexOf("const openingSendStartedAt = Date.now()", beginning); + assert.ok(beginning >= 0 && block > beginning && preflight > block); + const guard = lifecycle.slice(block, preflight); + assert.match(guard, /inlineTags\.has\(match\[1\]\.toLowerCase\(\)\)/); + assert.match(guard, /Custom message contains unsupported BBCode\/HTML/); + assert.match(guard, /return;/); +}); From 48fb66260db8d8d614eeaf79eeb0a49d469299ea Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:38:21 +0100 Subject: [PATCH 16/26] fix(bonanza): preserve confirmed opening after UNIT3D bounded chat history rolls over --- userscripts/giveaway/src/06-lifecycle.js | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/userscripts/giveaway/src/06-lifecycle.js b/userscripts/giveaway/src/06-lifecycle.js index f73568b..a211dee 100644 --- a/userscripts/giveaway/src/06-lifecycle.js +++ b/userscripts/giveaway/src/06-lifecycle.js @@ -146,6 +146,26 @@ // Never guess between duplicate equal announcements. if (matches.length === 1) return matches[0]; if (matches.length > 1) return null; + + // UNIT3D's GET /api/chat/messages/{room} returns only the last + // configured chat.message_limit messages (currently 100) and + // does not implement pagination or the after_id query parameter. + // A previously CONFIRMED opening with a durable random proof + // becomes older than that window during busy chats. When a + // complete healthy latest-page has advanced beyond its saved + // message ID, retain that earlier confirmation. Never apply + // this shortcut to a pending, legacy or still-in-range opening. + const confirmedId = optionalFiniteNumber(checkpoint.messageId); + const pageIds = payload.data.map(message => + optionalFiniteNumber(message?.id) + ); + if (checkpoint.status === "confirmed" && hasUniqueProof && + Number.isSafeInteger(confirmedId) && confirmedId > afterId && + pageIds.length >= 100 && pageIds.every(id => + Number.isSafeInteger(id) && id > 0 + ) && Math.min(...pageIds) > confirmedId) { + return { messageId: confirmedId, historyEvicted: true }; + } } catch (error) { console.warn("[BON Giveaway] Opening reconciliation deferred:", error); } From d81297bf5ea642c2c5fdaf9d4a2aa94b60f2e6f4 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:38:34 +0100 Subject: [PATCH 17/26] fix(bonanza): safely restore proven openings after bounded chat history expires --- userscripts/giveaway/src/05-initialization.js | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/userscripts/giveaway/src/05-initialization.js b/userscripts/giveaway/src/05-initialization.js index a3ca1bc..70147e5 100644 --- a/userscripts/giveaway/src/05-initialization.js +++ b/userscripts/giveaway/src/05-initialization.js @@ -1955,10 +1955,32 @@ const savedSponsorStart = optionalFiniteNumber( snap.giveawayData.sponsorStartTs ); + // The latest-100 chat page may no longer include a previously + // confirmed announcement. Its persisted random proof and exact + // message ID can remain authoritative ONLY when the healthy + // bounded history has clearly advanced beyond that ID. There + // is no UNIT3D pagination endpoint to query older messages. + // Revalidate the persisted sponsor boundary conservatively; + // do not derive a new one from a later, unrelated chat message. + const historicalConfirmation = verified?.historyEvicted === true; + const historicalSponsorBoundaryOk = historicalConfirmation && + savedSponsorStart !== null && + Number(restoredCheckpoint.startedAt) === + Number(snap.giveawayData.openedAt) && + Number.isFinite(Number(restoredCheckpoint.sendCompletedAt)) && + Number(restoredCheckpoint.sendCompletedAt) >= + Number(restoredCheckpoint.startedAt) && + savedSponsorStart > Number(restoredCheckpoint.startedAt) && + savedSponsorStart <= Math.min( + Number(restoredCheckpoint.windowEndAt), + Number(restoredCheckpoint.sendCompletedAt) + 1000 + ); if (!verified || verified.messageId !== optionalFiniteNumber(restoredCheckpoint.messageId) || savedSponsorStart === null || - verified.sponsorStartTs !== savedSponsorStart) { + (historicalConfirmation + ? !historicalSponsorBoundaryOk + : verified.sponsorStartTs !== savedSponsorStart)) { logEvent("Confirmed opening recovery deferred", "Authoritative Main Chat could not reprove the exact saved opening and sponsor boundary."); window.alert( From a51dd5cb515c0a26f3bd6253aa6f0af4e706ab03 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:38:57 +0100 Subject: [PATCH 18/26] test(bonanza): simulate 100-message UNIT3D history rollover during restore --- tests/bonanza-opening-real-api.test.mjs | 46 +++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/tests/bonanza-opening-real-api.test.mjs b/tests/bonanza-opening-real-api.test.mjs index 7100e8f..6e2ef37 100644 --- a/tests/bonanza-opening-real-api.test.mjs +++ b/tests/bonanza-opening-real-api.test.mjs @@ -158,3 +158,49 @@ test("unsupported custom BBCode is rejected before public POST", () => { assert.match(guard, /Custom message contains unsupported BBCode\/HTML/); assert.match(guard, /return;/); }); + +const latest100 = Array.from({length:100}, (_, i) => ({ + id: 629181 + i, + user: {id:11,username:"participant"}, + message:"unrelated chat", + created_at:"2026-10-14T20:26:30+00:00" +})); + +test("a proven confirmed opening survives when the recent 100 chat messages have advanced past its ID", async () => { + const r = await verify(latest100, {status:"confirmed",checkpoint:{messageId:629180}}); + assert.equal(r.messageId,629180); + assert.equal(r.historyEvicted,true); +}); + +test("pending openings are NOT confirmed merely because history advanced", async () => { + assert.equal(await verify(latest100,{checkpoint:{messageId:629180}}),null); +}); + +test("incomplete chat history is insufficient to bypass publication proof", async () => { + assert.equal(await verify(latest100.slice(0,99), { + status:"confirmed",checkpoint:{messageId:629180} + }),null); +}); + +test("a missing confirmed opening still inside the returned ID range is not treated as evicted", async () => { + const mixed=[{...latest100[0],id:629178},...latest100.slice(1)]; + assert.equal(await verify(mixed,{ + status:"confirmed",checkpoint:{messageId:629180} + }),null); +}); + +test("legacy confirmed snapshots without the random proof cannot bypass authoritative chat", async () => { + assert.equal(await verify(latest100,{ + status:"confirmed",checkpoint:{messageId:629180,openingProof:null} + }),null); +}); + +test("the history-expiry restore path retains the original sponsor boundary and fails closed for invalid dates", () => { + const init = readFileSync( + new URL("../userscripts/giveaway/src/05-initialization.js",import.meta.url),"utf8" + ); + assert.match(init,/verified\?\.historyEvicted === true/); + assert.match(init,/savedSponsorStart > Number\(restoredCheckpoint\.startedAt\)/); + assert.match(init,/Number\(restoredCheckpoint\.sendCompletedAt\) \+ 1000/); + assert.match(init,/verified\.sponsorStartTs !== savedSponsorStart/); +}); From 43452f4fd38a6d6a1c0dc968873cae2d40a97631 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:39:12 +0100 Subject: [PATCH 19/26] build(bonanza): synchronize verified history-rollover recovery --- .../DarkPeers_BONanza_Giveaway.user.js | 44 ++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js index 7a28447..da2ff27 100644 --- a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js +++ b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js @@ -3801,10 +3801,32 @@ body.host-panel-dragging * { const savedSponsorStart = optionalFiniteNumber( snap.giveawayData.sponsorStartTs ); + // The latest-100 chat page may no longer include a previously + // confirmed announcement. Its persisted random proof and exact + // message ID can remain authoritative ONLY when the healthy + // bounded history has clearly advanced beyond that ID. There + // is no UNIT3D pagination endpoint to query older messages. + // Revalidate the persisted sponsor boundary conservatively; + // do not derive a new one from a later, unrelated chat message. + const historicalConfirmation = verified?.historyEvicted === true; + const historicalSponsorBoundaryOk = historicalConfirmation && + savedSponsorStart !== null && + Number(restoredCheckpoint.startedAt) === + Number(snap.giveawayData.openedAt) && + Number.isFinite(Number(restoredCheckpoint.sendCompletedAt)) && + Number(restoredCheckpoint.sendCompletedAt) >= + Number(restoredCheckpoint.startedAt) && + savedSponsorStart > Number(restoredCheckpoint.startedAt) && + savedSponsorStart <= Math.min( + Number(restoredCheckpoint.windowEndAt), + Number(restoredCheckpoint.sendCompletedAt) + 1000 + ); if (!verified || verified.messageId !== optionalFiniteNumber(restoredCheckpoint.messageId) || savedSponsorStart === null || - verified.sponsorStartTs !== savedSponsorStart) { + (historicalConfirmation + ? !historicalSponsorBoundaryOk + : verified.sponsorStartTs !== savedSponsorStart)) { logEvent("Confirmed opening recovery deferred", "Authoritative Main Chat could not reprove the exact saved opening and sponsor boundary."); window.alert( @@ -4429,6 +4451,26 @@ body.host-panel-dragging * { // Never guess between duplicate equal announcements. if (matches.length === 1) return matches[0]; if (matches.length > 1) return null; + + // UNIT3D's GET /api/chat/messages/{room} returns only the last + // configured chat.message_limit messages (currently 100) and + // does not implement pagination or the after_id query parameter. + // A previously CONFIRMED opening with a durable random proof + // becomes older than that window during busy chats. When a + // complete healthy latest-page has advanced beyond its saved + // message ID, retain that earlier confirmation. Never apply + // this shortcut to a pending, legacy or still-in-range opening. + const confirmedId = optionalFiniteNumber(checkpoint.messageId); + const pageIds = payload.data.map(message => + optionalFiniteNumber(message?.id) + ); + if (checkpoint.status === "confirmed" && hasUniqueProof && + Number.isSafeInteger(confirmedId) && confirmedId > afterId && + pageIds.length >= 100 && pageIds.every(id => + Number.isSafeInteger(id) && id > 0 + ) && Math.min(...pageIds) > confirmedId) { + return { messageId: confirmedId, historyEvicted: true }; + } } catch (error) { console.warn("[BON Giveaway] Opening reconciliation deferred:", error); } From 75159b15949e6c1b1c8832ca69feb22aa7a62143 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:40:16 +0100 Subject: [PATCH 20/26] docs(bonanza): explain bounded UNIT3D chat restore invariant --- AGENTS.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 1d841d1..f92a952 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,6 +72,14 @@ multiple userscripts, not just BONanza. re-post blindly, or settle BON. Restore must verify the exact own-user announcement in authoritative Main Chat history before enabling entries or any sponsor/payout action. + Exception for a **previously confirmed** opening: UNIT3D's chat GET + returns only the latest configured messages (observed: 100), ignores + `after_id`, and offers no pagination for this endpoint. When a healthy, + full latest page begins strictly after the durable confirmed message ID, + recovery may rely on the ORIGINAL recorded message ID and random proof, + with the unchanged, bounded sponsor-start timestamp revalidated. This + exception NEVER applies to a pending opening, a message still within the + returned ID range, a short/failed response, or a legacy proofless snapshot. - The 45-minute absolute deadline is anchored to the conservative **pre-send** time, so server response latency cannot extend the event. **Sponsor eligibility must NOT use that time.** It begins strictly after From 98db4dd23154a6fa5c69cf823693c6426dd504b6 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:40:20 +0100 Subject: [PATCH 21/26] docs(bonanza): document real UNIT3D chat history window --- docs/runbooks/bonanza-operations.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/runbooks/bonanza-operations.md b/docs/runbooks/bonanza-operations.md index 3468406..122109d 100644 --- a/docs/runbooks/bonanza-operations.md +++ b/docs/runbooks/bonanza-operations.md @@ -112,3 +112,21 @@ proof to disambiguate whole-second API timestamps, and older snapshots cannot be silently upgraded to that proof. Rehearsal mode cannot establish that an invisible proof survives the real Main Chat rendering pipeline; validate this against the site before merging a new release. + +### Bounded Main Chat history + +The stock UNIT3D `GET /api/chat/messages/{room_id}` endpoint reads +`ChatRepository::messages()`, returning the newest `chat.message_limit` +messages (100 in the reviewed UNIT3D configuration). It does **not** support +pagination or `after_id`; sending `?page=2` does not retrieve older messages. +DarkPeers was independently observed returning precisely 100 messages. + +A LIVE opening previously confirmed with a durable random marker and message +ID can outlive that bounded page. Restore uses the original persisted +confirmation ONLY if a healthy full page has a minimum ID strictly beyond +the saved confirmed message ID, and the persisted sponsor eligibility boundary +is still valid. Pending or proofless openings and missing messages still +within the API page remain fail-closed. This fallback cannot prove whether +the original public message was subsequently deleted: it deliberately +prioritizes a durably verified opening over rechecking evicted history. +Never extend the 45-minute deadline or redetermine sponsors at reload. From f4c92ea86d5b7d447787e27b3e38303520d2c128 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:48:49 +0100 Subject: [PATCH 22/26] fix(bonanza): reject duplicate opening proofs even outside send window --- userscripts/giveaway/src/06-lifecycle.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/userscripts/giveaway/src/06-lifecycle.js b/userscripts/giveaway/src/06-lifecycle.js index a211dee..72fd7d3 100644 --- a/userscripts/giveaway/src/06-lifecycle.js +++ b/userscripts/giveaway/src/06-lifecycle.js @@ -103,6 +103,11 @@ if (!ownsTabLock()) return null; if (!Array.isArray(payload?.data)) throw new Error("Malformed Main Chat messages"); const matches = []; + // Track every matching announcement after the pre-send cursor, + // including posts OUTSIDE the permitted timestamp interval. + // Otherwise an out-of-window duplicate could be silently + // discarded, leaving a misleading single valid candidate. + const matchingAnnouncementIds = new Set(); for (const message of payload.data) { const id = optionalFiniteNumber(message?.id); if (!Number.isSafeInteger(id) || id <= afterId) continue; @@ -123,6 +128,7 @@ if (!expectedHost || serverName !== expectedHost) continue; } if (canonical(message?.message, true) !== expected) continue; + matchingAnnouncementIds.add(id); const rawTs = String(message?.created_at || ""); const timestamp = Date.parse(rawTs); const resolutionMs = unit3dTimestampResolutionMs(rawTs); @@ -143,7 +149,11 @@ sponsorStartTs: Math.max(startedAt, timestamp + resolutionMs) }); } - // Never guess between duplicate equal announcements. + // Reconcile only when there is ONE candidate across the + // entire returned Main Chat history, not just one within the + // timestamp window. This cannot establish uniqueness for an + // announcement already evicted by the fixed 100-message limit. + if (matchingAnnouncementIds.size > 1) return null; if (matches.length === 1) return matches[0]; if (matches.length > 1) return null; From b1c14896a185d4d9d8674414543b9c5fbbaebf78 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:49:03 +0100 Subject: [PATCH 23/26] test(bonanza): reject repeated opening proof beyond timestamp boundaries --- tests/bonanza-opening-real-api.test.mjs | 27 +++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/bonanza-opening-real-api.test.mjs b/tests/bonanza-opening-real-api.test.mjs index 6e2ef37..360837a 100644 --- a/tests/bonanza-opening-real-api.test.mjs +++ b/tests/bonanza-opening-real-api.test.mjs @@ -204,3 +204,30 @@ test("the history-expiry restore path retains the original sponsor boundary and assert.match(init,/Number\(restoredCheckpoint\.sendCompletedAt\) \+ 1000/); assert.match(init,/verified\.sponsorStartTs !== savedSponsorStart/); }); + +test("a matching duplicate AFTER the opening POST window disqualifies a seemingly unique in-window announcement", async () => { + const duplicate = real(629181, { + created_at: "2026-10-14T20:25:40+00:00" + }); + assert.equal(await verify([real(629180), duplicate]), null); +}); + +test("a matching duplicate BEFORE the attempt but after its message cursor also disqualifies opening", async () => { + const earlier = real(629177, { + created_at: "2026-10-14T20:25:23+00:00" + }); + assert.equal(await verify([earlier, real(629180)]), null); +}); + +test("a duplicated row with the SAME message ID does not count as two announcements", async () => { + const record = real(629180); + const result = await verify([record, {...record}]); + assert.equal(result.messageId, 629180); +}); + +test("timestamp-invalid duplicate with the same proof also blocks ambiguous opening", async () => { + assert.equal(await verify([ + real(629180), + real(629181, {created_at: "unknown"}) + ]), null); +}); From 51e257c756c8e4a8b5a3b813de039d8f4c550b92 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:49:17 +0100 Subject: [PATCH 24/26] build(bonanza): sync duplicate-proof verification safety guard --- .../giveaway/DarkPeers_BONanza_Giveaway.user.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js index da2ff27..abda017 100644 --- a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js +++ b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js @@ -4408,6 +4408,11 @@ body.host-panel-dragging * { if (!ownsTabLock()) return null; if (!Array.isArray(payload?.data)) throw new Error("Malformed Main Chat messages"); const matches = []; + // Track every matching announcement after the pre-send cursor, + // including posts OUTSIDE the permitted timestamp interval. + // Otherwise an out-of-window duplicate could be silently + // discarded, leaving a misleading single valid candidate. + const matchingAnnouncementIds = new Set(); for (const message of payload.data) { const id = optionalFiniteNumber(message?.id); if (!Number.isSafeInteger(id) || id <= afterId) continue; @@ -4428,6 +4433,7 @@ body.host-panel-dragging * { if (!expectedHost || serverName !== expectedHost) continue; } if (canonical(message?.message, true) !== expected) continue; + matchingAnnouncementIds.add(id); const rawTs = String(message?.created_at || ""); const timestamp = Date.parse(rawTs); const resolutionMs = unit3dTimestampResolutionMs(rawTs); @@ -4448,7 +4454,11 @@ body.host-panel-dragging * { sponsorStartTs: Math.max(startedAt, timestamp + resolutionMs) }); } - // Never guess between duplicate equal announcements. + // Reconcile only when there is ONE candidate across the + // entire returned Main Chat history, not just one within the + // timestamp window. This cannot establish uniqueness for an + // announcement already evicted by the fixed 100-message limit. + if (matchingAnnouncementIds.size > 1) return null; if (matches.length === 1) return matches[0]; if (matches.length > 1) return null; From 128b6c725104f9f7f897aa7b60c6a9f947c836b9 Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:50:07 +0100 Subject: [PATCH 25/26] fix(bonanza): deduplicate repeated API rows by message ID without accepting distinct opening duplicates --- userscripts/giveaway/src/06-lifecycle.js | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/userscripts/giveaway/src/06-lifecycle.js b/userscripts/giveaway/src/06-lifecycle.js index 72fd7d3..0a38972 100644 --- a/userscripts/giveaway/src/06-lifecycle.js +++ b/userscripts/giveaway/src/06-lifecycle.js @@ -144,10 +144,15 @@ if (!whollyInside && !(hasUniqueProof && overlapsAttempt && canonical(message?.message, true).endsWith(proof))) continue; - matches.push({ - messageId: id, - sponsorStartTs: Math.max(startedAt, timestamp + resolutionMs) - }); + // An API replay can contain the very same record twice. + // One message ID is one announcement; two distinct IDs + // with this proof are always ambiguous. + if (!matches.some(match => match.messageId === id)) { + matches.push({ + messageId: id, + sponsorStartTs: Math.max(startedAt, timestamp + resolutionMs) + }); + } } // Reconcile only when there is ONE candidate across the // entire returned Main Chat history, not just one within the From d188d45c3af654e969fcd7339a0271b9768b1fcd Mon Sep 17 00:00:00 2001 From: Maghuro <1546139+maghuro@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:50:19 +0100 Subject: [PATCH 26/26] build(bonanza): sync de-duplicated authoritative chat candidate matching --- .../giveaway/DarkPeers_BONanza_Giveaway.user.js | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js index abda017..3f90872 100644 --- a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js +++ b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js @@ -4449,10 +4449,15 @@ body.host-panel-dragging * { if (!whollyInside && !(hasUniqueProof && overlapsAttempt && canonical(message?.message, true).endsWith(proof))) continue; - matches.push({ - messageId: id, - sponsorStartTs: Math.max(startedAt, timestamp + resolutionMs) - }); + // An API replay can contain the very same record twice. + // One message ID is one announcement; two distinct IDs + // with this proof are always ambiguous. + if (!matches.some(match => match.messageId === id)) { + matches.push({ + messageId: id, + sponsorStartTs: Math.max(startedAt, timestamp + resolutionMs) + }); + } } // Reconcile only when there is ONE candidate across the // entire returned Main Chat history, not just one within the