diff --git a/tests/bonanza-review-hardening.test.mjs b/tests/bonanza-review-hardening.test.mjs
index b864bca9..25d2f713 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\.10$/m);
+ assert.match(source, /^\/\/ @version\s+1\.5\.11$/m);
assert.doesNotMatch(source, /pollChatFallback/);
assert.doesNotMatch(source, /onlyguardians/i);
assert.match(source, /async function getLatestMainChatReplayBoundary\(\)/);
@@ -72,6 +72,19 @@ test("winner-count commands are host-only", () => {
assert.doesNotMatch(maxWinners, /isHostOrAdmin/);
});
+test("host virtual BON stays isolated from sponsor gifts", () => {
+ assert.match(source, /hostWalletAtStart: null/);
+ assert.match(source, /hostWalletAtStart: giveawayData\.hostWalletAtStart/);
+ assert.match(source, /function getHostVirtualBon\(data = giveawayData\)/);
+ assert.match(source, /Math\.floor\(walletAtStart\) - hostCommitted/);
+ assert.match(source, /My Virtual BON/);
+ assert.match(source, /sponsor gifts do not affect this balance/);
+ assert.match(source, /const virtualBon = getHostVirtualBon\(giveawayData\)/);
+ assert.match(source, /amount > virtualBon/);
+ assert.match(source, /giveawayData\.hostAdded = \(giveawayData\.hostAdded \|\| 0\) \+ amount/);
+ assert.match(source, /updateHostPanelUI\(\)/);
+});
+
test("staff emergency controls remain available", () => {
const required = [
/rig\(ctx\)[\s\S]*?isHostOrAdmin/,
diff --git a/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js b/userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js
index 61c7fb15..2d74421a 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.10
+// @version 1.5.11
// @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
@@ -1680,6 +1680,38 @@ body.host-panel-dragging * {
white-space: normal;
word-break: break-word;
}
+.host-command-panel__virtual-bon {
+ border: 1px solid rgba(255, 192, 10, 0.38);
+ border-radius: 6px;
+ background: rgba(255, 192, 10, 0.07);
+ padding: 9px 10px;
+ min-width: 0;
+}
+.host-command-panel__virtual-bon-main {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 10px;
+}
+.host-command-panel__virtual-bon-label {
+ color: #ffa200;
+ font-size: 12px;
+ font-weight: 700;
+}
+.host-command-panel__virtual-bon-value {
+ color: #ffc00a;
+ font-size: 15px;
+ font-weight: 800;
+ text-align: right;
+}
+.host-command-panel__virtual-bon-detail {
+ display: block;
+ margin-top: 3px;
+ color: #aaa;
+ font-size: 10px;
+ line-height: 1.3;
+}
+
.host-command-panel .host-command-panel__row {
margin-bottom: 6px;
min-width: 0;
@@ -3108,6 +3140,7 @@ body.host-panel-dragging * {
customMessage: giveawayData.customMessage,
donationPercent: giveawayData.donationPercent,
hostAdded: giveawayData.hostAdded,
+ hostWalletAtStart: giveawayData.hostWalletAtStart,
initialPotVerifiedAtStart: giveawayData.initialPotVerifiedAtStart,
reminderSchedule: giveawayData.reminderSchedule,
reminderNum: giveawayData.reminderNum,
@@ -3431,6 +3464,10 @@ body.host-panel-dragging * {
giveawayData.sponsorGiftMessages = Array.isArray(giveawayData.sponsorGiftMessages)
? giveawayData.sponsorGiftMessages
: [];
+ const restoredHostWalletAtStart = Number(giveawayData.hostWalletAtStart);
+ giveawayData.hostWalletAtStart = (
+ Number.isFinite(restoredHostWalletAtStart) && restoredHostWalletAtStart >= 0
+ ) ? Math.floor(restoredHostWalletAtStart) : null;
// Old active snapshots must not expose a start-time draw. A committed
// settlement must keep its exact draw so crash/reload recovery cannot
// select a different winner.
@@ -3950,6 +3987,7 @@ body.host-panel-dragging * {
customMessage: customMessageInput.value,
donationPercent: normalizeDonationPercent(donationPercentInput ? donationPercentInput.value : 0),
hostAdded: amountInt,
+ hostWalletAtStart: null,
initialPotVerifiedAtStart: amountInt,
reminderSchedule : schedule,
reminderNum : schedule.length,
@@ -3984,6 +4022,10 @@ body.host-panel-dragging * {
return;
}
else {
+ // Freeze the host's own pre-giveaway wallet. Sponsor gifts increase the
+ // real DarkPeers wallet and the giveaway pot together, but must not make
+ // the host appear to have more personal BON available to commit.
+ giveawayData.hostWalletAtStart = Math.max(0, Math.floor(currentBon));
giveawayData.initialPotVerifiedAtStart = giveawayData.amount;
recomputeEffectiveWinners(giveawayData);
initializeScaledWinnersAnnouncementState(giveawayData);
@@ -7543,6 +7585,16 @@ body.host-panel-dragging * {
return;
}
+ // The host's virtual BON is deliberately frozen against sponsor gifts.
+ // Reject impossible top-ups locally before spending another DarkPeers request.
+ const virtualBon = getHostVirtualBon(giveawayData);
+ if (Number.isFinite(virtualBon) && amount > virtualBon) {
+ reply(
+ `[b][color=red]You only have ${fmtBONCurrency(virtualBon)} virtual BON available to add from your own balance.[/color][/b]`
+ );
+ return;
+ }
+
// Serialize the balance check and mutation as one host transaction.
hostAddBonInFlight = true;
try {
@@ -7582,6 +7634,7 @@ body.host-panel-dragging * {
// ✅ host-only tracking (excludes sponsors)
giveawayData.hostAdded = (giveawayData.hostAdded || 0) + amount;
+ updateHostPanelUI();
const newEffectiveWinners = recomputeEffectiveWinners(giveawayData);
const winnersDelta = Math.max(0, newEffectiveWinners - prevEffectiveWinners);
@@ -7622,7 +7675,7 @@ body.host-panel-dragging * {
} else {
logEvent(
"Host BON top-up recorded",
- `Host added ${fmtBONCurrency(amount)} BON | pot=${fmtBONCurrency(newTotal)} BON | verified wallet=${fmtBONCurrency(currentBon)} BON`
+ `Host added ${fmtBONCurrency(amount)} BON | pot=${fmtBONCurrency(newTotal)} BON | verified wallet=${fmtBONCurrency(currentBon)} BON | virtual remaining=${fmtBONCurrency(getHostVirtualBon(giveawayData) ?? 0)} BON`
);
}
} finally {
@@ -13229,6 +13282,15 @@ body.host-panel-dragging * {
return line;
}
+ function getHostVirtualBon(data = giveawayData) {
+ if (!data) return null;
+ const walletAtStart = Number(data.hostWalletAtStart);
+ if (!Number.isFinite(walletAtStart) || walletAtStart < 0) return null;
+
+ const hostCommitted = Math.max(0, Math.floor(Number(data.hostAdded) || 0));
+ return Math.max(0, Math.floor(walletAtStart) - hostCommitted);
+ }
+
function isCurrentUserGiveawayHost() {
const navDisplayName = getTopNavUserLink()?.textContent || "";
const selfNames = [getLoggedInUsername(), navDisplayName].map((name) => normUserKey(name)).filter(Boolean);
@@ -13572,10 +13634,51 @@ body.host-panel-dragging * {
});
}
+ function renderHostVirtualBonCard() {
+ if (!hostCommandPanelBody) return;
+
+ let card = hostCommandPanelBody.querySelector(".host-command-panel__virtual-bon");
+ if (!card) {
+ card = document.createElement("section");
+ card.className = "host-command-panel__virtual-bon";
+ card.innerHTML = `
+
+ My Virtual BON
+ —
+
+ `;
+ hostCommandPanelBody.prepend(card);
+ }
+
+ const value = card.querySelector(".host-command-panel__virtual-bon-value");
+ const detail = card.querySelector(".host-command-panel__virtual-bon-detail");
+ if (!value || !detail) return;
+
+ if (!giveawayData) {
+ value.textContent = "—";
+ detail.textContent = "Start a giveaway to track your own available BON.";
+ return;
+ }
+
+ const virtualBon = getHostVirtualBon(giveawayData);
+ if (!Number.isFinite(virtualBon)) {
+ value.textContent = "Unavailable";
+ detail.textContent = "This giveaway predates virtual BON tracking.";
+ return;
+ }
+
+ const walletAtStart = Math.max(0, Math.floor(Number(giveawayData.hostWalletAtStart) || 0));
+ const hostCommitted = Math.max(0, Math.floor(Number(giveawayData.hostAdded) || 0));
+ value.textContent = `${fmtBONCurrency(virtualBon)} BON`;
+ detail.textContent =
+ `Start: ${fmtBONCurrency(walletAtStart)} BON · committed by you: ${fmtBONCurrency(hostCommitted)} BON · sponsor gifts do not affect this balance.`;
+ }
+
function renderHostPanelCommands() {
if (!hostCommandPanelBody) return;
hostPanelCommandState.clear();
hostCommandPanelBody.innerHTML = "";
+ renderHostVirtualBonCard();
const panelList = getPanelCommandList();
if (!panelList || !Array.isArray(panelList.commands)) {
@@ -14080,6 +14183,7 @@ body.host-panel-dragging * {
function updateHostPanelUI() {
const visible = true;
+ renderHostVirtualBonCard();
if (hostPanelToggleBtn) hostPanelToggleBtn.style.display = visible ? "inline-flex" : "none";
if (hostCommandPanel && !visible) setHostPanelOpen(false);
diff --git a/userscripts/giveaway/src/00-preamble.js b/userscripts/giveaway/src/00-preamble.js
index 1984355e..310ae887 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.10
+// @version 1.5.11
// @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
diff --git a/userscripts/giveaway/src/04-ui-template.js b/userscripts/giveaway/src/04-ui-template.js
index 879701f4..bae25419 100644
--- a/userscripts/giveaway/src/04-ui-template.js
+++ b/userscripts/giveaway/src/04-ui-template.js
@@ -636,6 +636,38 @@ body.host-panel-dragging * {
white-space: normal;
word-break: break-word;
}
+.host-command-panel__virtual-bon {
+ border: 1px solid rgba(255, 192, 10, 0.38);
+ border-radius: 6px;
+ background: rgba(255, 192, 10, 0.07);
+ padding: 9px 10px;
+ min-width: 0;
+}
+.host-command-panel__virtual-bon-main {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 10px;
+}
+.host-command-panel__virtual-bon-label {
+ color: #ffa200;
+ font-size: 12px;
+ font-weight: 700;
+}
+.host-command-panel__virtual-bon-value {
+ color: #ffc00a;
+ font-size: 15px;
+ font-weight: 800;
+ text-align: right;
+}
+.host-command-panel__virtual-bon-detail {
+ display: block;
+ margin-top: 3px;
+ color: #aaa;
+ font-size: 10px;
+ line-height: 1.3;
+}
+
.host-command-panel .host-command-panel__row {
margin-bottom: 6px;
min-width: 0;
diff --git a/userscripts/giveaway/src/05-initialization.js b/userscripts/giveaway/src/05-initialization.js
index b6da6ec7..d073b202 100644
--- a/userscripts/giveaway/src/05-initialization.js
+++ b/userscripts/giveaway/src/05-initialization.js
@@ -1344,6 +1344,7 @@
customMessage: giveawayData.customMessage,
donationPercent: giveawayData.donationPercent,
hostAdded: giveawayData.hostAdded,
+ hostWalletAtStart: giveawayData.hostWalletAtStart,
initialPotVerifiedAtStart: giveawayData.initialPotVerifiedAtStart,
reminderSchedule: giveawayData.reminderSchedule,
reminderNum: giveawayData.reminderNum,
@@ -1667,6 +1668,10 @@
giveawayData.sponsorGiftMessages = Array.isArray(giveawayData.sponsorGiftMessages)
? giveawayData.sponsorGiftMessages
: [];
+ const restoredHostWalletAtStart = Number(giveawayData.hostWalletAtStart);
+ giveawayData.hostWalletAtStart = (
+ Number.isFinite(restoredHostWalletAtStart) && restoredHostWalletAtStart >= 0
+ ) ? Math.floor(restoredHostWalletAtStart) : null;
// Old active snapshots must not expose a start-time draw. A committed
// settlement must keep its exact draw so crash/reload recovery cannot
// select a different winner.
diff --git a/userscripts/giveaway/src/06-lifecycle.js b/userscripts/giveaway/src/06-lifecycle.js
index e212075a..7a94cf77 100644
--- a/userscripts/giveaway/src/06-lifecycle.js
+++ b/userscripts/giveaway/src/06-lifecycle.js
@@ -148,6 +148,7 @@
customMessage: customMessageInput.value,
donationPercent: normalizeDonationPercent(donationPercentInput ? donationPercentInput.value : 0),
hostAdded: amountInt,
+ hostWalletAtStart: null,
initialPotVerifiedAtStart: amountInt,
reminderSchedule : schedule,
reminderNum : schedule.length,
@@ -182,6 +183,10 @@
return;
}
else {
+ // Freeze the host's own pre-giveaway wallet. Sponsor gifts increase the
+ // real DarkPeers wallet and the giveaway pot together, but must not make
+ // the host appear to have more personal BON available to commit.
+ giveawayData.hostWalletAtStart = Math.max(0, Math.floor(currentBon));
giveawayData.initialPotVerifiedAtStart = giveawayData.amount;
recomputeEffectiveWinners(giveawayData);
initializeScaledWinnersAnnouncementState(giveawayData);
diff --git a/userscripts/giveaway/src/10-command-handling.js b/userscripts/giveaway/src/10-command-handling.js
index 15b2de9c..38a72ac8 100644
--- a/userscripts/giveaway/src/10-command-handling.js
+++ b/userscripts/giveaway/src/10-command-handling.js
@@ -1057,6 +1057,16 @@
return;
}
+ // The host's virtual BON is deliberately frozen against sponsor gifts.
+ // Reject impossible top-ups locally before spending another DarkPeers request.
+ const virtualBon = getHostVirtualBon(giveawayData);
+ if (Number.isFinite(virtualBon) && amount > virtualBon) {
+ reply(
+ `[b][color=red]You only have ${fmtBONCurrency(virtualBon)} virtual BON available to add from your own balance.[/color][/b]`
+ );
+ return;
+ }
+
// Serialize the balance check and mutation as one host transaction.
hostAddBonInFlight = true;
try {
@@ -1096,6 +1106,7 @@
// ✅ host-only tracking (excludes sponsors)
giveawayData.hostAdded = (giveawayData.hostAdded || 0) + amount;
+ updateHostPanelUI();
const newEffectiveWinners = recomputeEffectiveWinners(giveawayData);
const winnersDelta = Math.max(0, newEffectiveWinners - prevEffectiveWinners);
@@ -1136,7 +1147,7 @@
} else {
logEvent(
"Host BON top-up recorded",
- `Host added ${fmtBONCurrency(amount)} BON | pot=${fmtBONCurrency(newTotal)} BON | verified wallet=${fmtBONCurrency(currentBon)} BON`
+ `Host added ${fmtBONCurrency(amount)} BON | pot=${fmtBONCurrency(newTotal)} BON | verified wallet=${fmtBONCurrency(currentBon)} BON | virtual remaining=${fmtBONCurrency(getHostVirtualBon(giveawayData) ?? 0)} BON`
);
}
} finally {
diff --git a/userscripts/giveaway/src/13-menu-validation.js b/userscripts/giveaway/src/13-menu-validation.js
index 565a682a..4bac091c 100644
--- a/userscripts/giveaway/src/13-menu-validation.js
+++ b/userscripts/giveaway/src/13-menu-validation.js
@@ -370,6 +370,15 @@
return line;
}
+ function getHostVirtualBon(data = giveawayData) {
+ if (!data) return null;
+ const walletAtStart = Number(data.hostWalletAtStart);
+ if (!Number.isFinite(walletAtStart) || walletAtStart < 0) return null;
+
+ const hostCommitted = Math.max(0, Math.floor(Number(data.hostAdded) || 0));
+ return Math.max(0, Math.floor(walletAtStart) - hostCommitted);
+ }
+
function isCurrentUserGiveawayHost() {
const navDisplayName = getTopNavUserLink()?.textContent || "";
const selfNames = [getLoggedInUsername(), navDisplayName].map((name) => normUserKey(name)).filter(Boolean);
@@ -713,10 +722,51 @@
});
}
+ function renderHostVirtualBonCard() {
+ if (!hostCommandPanelBody) return;
+
+ let card = hostCommandPanelBody.querySelector(".host-command-panel__virtual-bon");
+ if (!card) {
+ card = document.createElement("section");
+ card.className = "host-command-panel__virtual-bon";
+ card.innerHTML = `
+
+ My Virtual BON
+ —
+
+ `;
+ hostCommandPanelBody.prepend(card);
+ }
+
+ const value = card.querySelector(".host-command-panel__virtual-bon-value");
+ const detail = card.querySelector(".host-command-panel__virtual-bon-detail");
+ if (!value || !detail) return;
+
+ if (!giveawayData) {
+ value.textContent = "—";
+ detail.textContent = "Start a giveaway to track your own available BON.";
+ return;
+ }
+
+ const virtualBon = getHostVirtualBon(giveawayData);
+ if (!Number.isFinite(virtualBon)) {
+ value.textContent = "Unavailable";
+ detail.textContent = "This giveaway predates virtual BON tracking.";
+ return;
+ }
+
+ const walletAtStart = Math.max(0, Math.floor(Number(giveawayData.hostWalletAtStart) || 0));
+ const hostCommitted = Math.max(0, Math.floor(Number(giveawayData.hostAdded) || 0));
+ value.textContent = `${fmtBONCurrency(virtualBon)} BON`;
+ detail.textContent =
+ `Start: ${fmtBONCurrency(walletAtStart)} BON · committed by you: ${fmtBONCurrency(hostCommitted)} BON · sponsor gifts do not affect this balance.`;
+ }
+
function renderHostPanelCommands() {
if (!hostCommandPanelBody) return;
hostPanelCommandState.clear();
hostCommandPanelBody.innerHTML = "";
+ renderHostVirtualBonCard();
const panelList = getPanelCommandList();
if (!panelList || !Array.isArray(panelList.commands)) {
@@ -1221,6 +1271,7 @@
function updateHostPanelUI() {
const visible = true;
+ renderHostVirtualBonCard();
if (hostPanelToggleBtn) hostPanelToggleBtn.style.display = visible ? "inline-flex" : "none";
if (hostCommandPanel && !visible) setHostPanelOpen(false);