Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions tests/bonanza-pure-core.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,107 @@ test("chat number formatter still normalizes genuine thousands-grouped amounts",
);
});

function loadRejectedGiftRestore({ owns = true, terminalMarker = 123456789, writeSucceeds = true } = {}) {
const giveawayId = "giveaway-1";
const giftKey = "winner::100::winner";
const retryKey = "retry-key";
const storage = new Map();
let ledger = {
[giveawayId]: {
[giftKey]: terminalMarker
}
};

const context = makeContext({
ownsTabLock() { return owns; },
paidGiftKey() { return giftKey; },
paidGiftRetryableKey() { return retryKey; },
readPaidGiftsLedger() {
return JSON.parse(JSON.stringify(ledger));
},
writePaidGiftsLedger(next) {
if (!writeSucceeds) return false;
ledger = JSON.parse(JSON.stringify(next));
return true;
},
localStorage: {
setItem(key, value) { storage.set(String(key), String(value)); },
getItem(key) { return storage.has(String(key)) ? storage.get(String(key)) : null; }
}
});

const restore = extractBraceBlock(
utilitiesSource,
"function restoreDefinitivelyRejectedGiftAttempt"
);
vm.runInContext(
`${restore}\nglobalThis.restoreDefinitivelyRejectedGiftAttempt = restoreDefinitivelyRejectedGiftAttempt;`,
context
);

return {
restore: context.restoreDefinitivelyRejectedGiftAttempt,
giveawayId,
terminalMarker,
getLedger: () => ledger,
storage,
giftKey,
retryKey
};
}

test("definitively rejected gift fallback restores only its exact terminal marker", () => {
const harness = loadRejectedGiftRestore();
const token = "tab-a:123:token";

assert.equal(
harness.restore(
harness.giveawayId,
"winner",
100,
"winner",
token,
harness.terminalMarker
),
true
);
assert.equal(harness.getLedger()[harness.giveawayId][harness.giftKey], token);
assert.equal(harness.storage.get(harness.retryKey), token);
});

test("rejected gift retry restore fails closed on marker mismatch or lost ownership", () => {
const mismatch = loadRejectedGiftRestore();
assert.equal(
mismatch.restore(
mismatch.giveawayId,
"winner",
100,
"winner",
"tab-a:123:token",
mismatch.terminalMarker + 1
),
false
);
assert.equal(
mismatch.getLedger()[mismatch.giveawayId][mismatch.giftKey],
mismatch.terminalMarker
);

const lost = loadRejectedGiftRestore({ owns: false });
assert.equal(
lost.restore(
lost.giveawayId,
"winner",
100,
"winner",
"tab-a:123:token",
lost.terminalMarker
),
false
);
assert.equal(lost.storage.size, 0);
});

function loadFinancialFunctions() {
const context = makeContext({
BONANZA: {
Expand Down
21 changes: 20 additions & 1 deletion tests/bonanza-review-hardening.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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\.13$/m);
assert.match(source, /^\/\/ @version\s+1\.5\.14$/m);
assert.doesNotMatch(source, /pollChatFallback/);
assert.doesNotMatch(source, /onlyguardians/i);
assert.match(source, /async function getLatestMainChatReplayBoundary\(\)/);
Expand All @@ -28,6 +28,25 @@ test("review hardening invariants stay present", () => {
);
});

test("definitively rejected gift chat fallbacks remain retryable", () => {
assert.match(source, /function restoreDefinitivelyRejectedGiftAttempt\(/);
assert.match(source, /const terminalMarker = markGiftAttemptTerminal\(/);
assert.match(
source,
/await sendMessage\(cmd, \{[\s\S]*?requireExclusiveGiveawayOwnership: true,[\s\S]*?forceChatboxOnly: true[\s\S]*?\}\)/
);
assert.match(source, /const forceChatboxOnly = options\?\.forceChatboxOnly === true/);
assert.match(source, /if \(!forceChatboxOnly && !DEBUG_SETTINGS\.suppressApiMessages\)/);
assert.match(source, /if \(sent\) return \{ sent: true \}/);
assert.match(
source,
/restoreDefinitivelyRejectedGiftAttempt\([\s\S]*?attemptToken,[\s\S]*?terminalMarker[\s\S]*?\)/
);
assert.match(source, /reason: "chat-send-rejected"/);
assert.match(source, /reason: "attempt-ledger-unavailable"/);
assert.doesNotMatch(source, /await sendMessage\(cmd\);\s*return true;/);
});

test("BON gift notification cleanup is scoped and non-blocking", () => {
assert.match(source, /async function markGiveawayBonNotificationsRead\(\{ hostName, startTs, endTs \} = \{\}\)/);
assert.match(source, /giftNotificationOverlapsWindow\(notification, start, end\)/);
Expand Down
130 changes: 115 additions & 15 deletions userscripts/giveaway/DarkPeers_BONanza_Giveaway.user.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.13
// @version 1.5.14
// @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
Expand Down Expand Up @@ -240,6 +240,10 @@
// never collapsed into a fake thousands-grouped number. Usernames containing
// digits (for example Sch2021) no longer trigger unrelated formatting, while
// genuine 1,000 / 1 000 / 1'000-style amounts remain normalized with spaces.
// - v1.5.14 keeps a definitively rejected /gift chat fallback retryable. The
// fallback now preserves exclusive ownership, uses the chatbox-only slash-command
// path so an ambiguous chat-API timeout can never be mistaken for a definite
// no-send, and restores only the exact unsent terminal marker after durable read-back.
//// DarkPeers BONanza fork created and maintained by T.R.A.V.I.S. for the DarkPeers staff.
// Further development and maintenance by Maghuro & M.A.E.S.T.R.O.

Expand Down Expand Up @@ -11572,10 +11576,59 @@ body.host-panel-dragging * {

// A numeric value is the established terminal-attempt representation and
// remains compatible with ledgers written by older stable versions.
ledger[giveawayId][giftKey] = Date.now();
// Return the exact marker so a definitively rejected fallback can restore
// only the terminal value written by this specific attempt.
const terminalMarker = Date.now();
ledger[giveawayId][giftKey] = terminalMarker;
if (!writePaidGiftsLedger(ledger, { verifyWrite: true })) return false;
clearGiftAttemptRetryable(giveawayId, recipient, amount, purpose, attemptToken);
return true;
return terminalMarker;
}

function restoreDefinitivelyRejectedGiftAttempt(
giveawayId,
recipient,
amount,
purpose,
attemptToken,
terminalMarker
) {
if (
!giveawayId ||
!attemptToken ||
!Number.isFinite(Number(terminalMarker)) ||
!ownsTabLock()
) {
return false;
}

const ledger = readPaidGiftsLedger();
const giftKey = paidGiftKey(recipient, amount, purpose);
if (ledger[giveawayId]?.[giftKey] !== terminalMarker) return false;

// Publish the retryable marker before restoring the token. If the ledger
// rewrite fails, the old numeric terminal value still wins and the stale
// retry marker is harmless. If it succeeds, both durable pieces identify
// this exact known-unsent attempt as safe to retry.
const retryKey = paidGiftRetryableKey(giveawayId, recipient, amount, purpose);
try {
localStorage.setItem(retryKey, attemptToken);
if (localStorage.getItem(retryKey) !== attemptToken) return false;
} catch {
return false;
}

ledger[giveawayId][giftKey] = attemptToken;
if (!writePaidGiftsLedger(ledger, { verifyWrite: true })) return false;

const verified = readPaidGiftsLedger();
if (verified[giveawayId]?.[giftKey] !== attemptToken) return false;

try {
return localStorage.getItem(retryKey) === attemptToken;
} catch {
return false;
}
}

function recoverOrphanedPendingGiftAttempt(giveawayId, recipient, amount, purpose, attemptState) {
Expand Down Expand Up @@ -11735,38 +11788,76 @@ body.host-panel-dragging * {
"Gift fallback aborted (attempt superseded)",
`The transfer token for ${sanitizeNick(safeRecipient)} was replaced by a newer owner before fallback could run.`
);
return false;
return { sent: false, reason: "attempt-superseded" };
}

if (!(await ensureExclusiveTabOwnership())) {
logEvent(
"Gift fallback paused (ownership lost)",
`Refusing chat fallback for ${sanitizeNick(safeRecipient)} because this tab cannot prove exclusive giveaway ownership; the rejected attempt remains retryable.`
);
return false;
return { sent: false, reason: "ownership-lost" };
}

// We own the giveaway again and are about to make the fallback send
// ambiguous. Make this exact attempt terminal before sending.
if (!markGiftAttemptTerminal(
const terminalMarker = markGiftAttemptTerminal(
giveawayId,
safeRecipient,
safeAmount,
purpose,
attemptToken
)) {
);
if (!terminalMarker) {
logEvent(
"Gift fallback aborted (attempt superseded)",
`The transfer token for ${sanitizeNick(safeRecipient)} changed before the chat fallback send; refusing to risk a duplicate payment.`
);
return false;
return { sent: false, reason: "attempt-superseded" };
}

const cmd = safeMessage
? `/gift ${safeRecipient} ${safeAmount} ${safeMessage}`
: `/gift ${safeRecipient} ${safeAmount}`;
await sendMessage(cmd);
return true;

const sent = await sendMessage(cmd, {
requireExclusiveGiveawayOwnership: true,
forceChatboxOnly: true
});
if (sent) return { sent: true };

// This fallback is deliberately chatbox-only. Therefore a false result
// cannot hide an ambiguous /api/chat/messages timeout: no chat API
// request was attempted. If we still own the giveaway, the command was
// definitively not dispatched and this exact attempt is safe to retry.
if (!ownsTabLock()) {
logEvent(
"Gift fallback rejected after ownership loss",
`The chat fallback for ${sanitizeNick(safeRecipient)} was not sent and this tab no longer owns the giveaway; settlement will resume from the durable state.`
);
return { sent: false, reason: "ownership-lost" };
}

if (!restoreDefinitivelyRejectedGiftAttempt(
giveawayId,
safeRecipient,
safeAmount,
purpose,
attemptToken,
terminalMarker
)) {
logEvent(
"Gift fallback rejected (retry state unavailable)",
`The chat fallback for ${sanitizeNick(safeRecipient)} was definitively rejected, but its retryable ledger state could not be restored safely.`
);
return { sent: false, reason: "attempt-ledger-unavailable" };
}

logEvent(
"Gift fallback rejected (retryable)",
`The chat fallback for ${sanitizeNick(safeRecipient)} was definitively rejected before send; the exact transfer attempt remains safe to retry.`
);
return { sent: false, reason: "chat-send-rejected" };
}

const csrfMeta = document.querySelector('meta[name="csrf-token"]');
Expand All @@ -11785,8 +11876,9 @@ body.host-panel-dragging * {
// If we can't resolve the HTTP endpoint or token, fall back immediately.
// This is safe: we haven't sent anything yet, so /gift is the first attempt.
if (!csrfToken || !giftUrl) {
if (!(await fallbackToChat())) {
return { attempted: false, reason: "ownership-lost" };
const fallback = await fallbackToChat();
if (!fallback.sent) {
return { attempted: false, reason: fallback.reason || "chat-send-rejected" };
}
return { attempted: true, transport: "chat" };
}
Expand Down Expand Up @@ -11820,8 +11912,13 @@ body.host-panel-dragging * {
"Gift HTTP rejected, falling back",
`${sanitizeNick(safeRecipient)} ${fmtBONCurrency(safeAmount)} BON | status=${resp.status}`
);
if (!(await fallbackToChat())) {
return { attempted: false, reason: "ownership-lost", httpStatus: resp.status };
const fallback = await fallbackToChat();
if (!fallback.sent) {
return {
attempted: false,
reason: fallback.reason || "chat-send-rejected",
httpStatus: resp.status
};
}
return { attempted: true, transport: "chat-fallback", httpStatus: resp.status };
}
Expand Down Expand Up @@ -12039,6 +12136,7 @@ body.host-panel-dragging * {
options?.requireExclusiveGiveawayOwnership === true;
const allowRehearsalPrivateOutput =
options?.rehearsalPrivateOutput === true;
const forceChatboxOnly = options?.forceChatboxOnly === true;

if (REHEARSAL_MODE && !allowRehearsalPrivateOutput) {
const rehearsalHost = String(giveawayData?.host || getLoggedInUsername() || "").trim();
Expand Down Expand Up @@ -12075,7 +12173,9 @@ body.host-panel-dragging * {
if (!OT_USER_ID || !OT_CHATROOM_ID || !OT_CSRF_TOKEN) cacheChatContext();

// --- Attempt API POST, fall back to chatbox on failure ---
if (!DEBUG_SETTINGS.suppressApiMessages) {
// Slash-command gift fallbacks can force the synchronous chatbox path so
// an ambiguous API timeout can never be mistaken for a definite no-send.
if (!forceChatboxOnly && !DEBUG_SETTINGS.suppressApiMessages) {
try {
if (await trySendViaApi(messageStr)) return true;
} catch (e) {
Expand Down
6 changes: 5 additions & 1 deletion userscripts/giveaway/src/00-preamble.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.13
// @version 1.5.14
// @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
Expand Down Expand Up @@ -240,6 +240,10 @@
// never collapsed into a fake thousands-grouped number. Usernames containing
// digits (for example Sch2021) no longer trigger unrelated formatting, while
// genuine 1,000 / 1 000 / 1'000-style amounts remain normalized with spaces.
// - v1.5.14 keeps a definitively rejected /gift chat fallback retryable. The
// fallback now preserves exclusive ownership, uses the chatbox-only slash-command
// path so an ambiguous chat-API timeout can never be mistaken for a definite
// no-send, and restores only the exact unsent terminal marker after durable read-back.
//// DarkPeers BONanza fork created and maintained by T.R.A.V.I.S. for the DarkPeers staff.
// Further development and maintenance by Maghuro & M.A.E.S.T.R.O.

Expand Down
Loading
Loading