mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-02 19:02:28 +00:00
[hashtags] Monotonic created_at so a rapid second edit doesn't false-error (#57)
Adding a second hashtag interest/disinterest quickly showed "didn't work" yet the change persisted after a refresh. Cause: the replaceable preferences event used created_at = floor(now/1000), so two edits in the same wall-clock second produced the SAME created_at. Relays reject the second as "not newer" (client shows an error) while relays that break created_at ties by id still store it (so it appears after a refresh) — an inconsistent false-negative. Fix: nextReplaceableCreatedAt(lastCreatedAt) (js/nostr/replaceableCreatedAt.js) — always advance to max(now, lastCreatedAt + 1), so each publish is strictly newer than the last and every relay accepts it unambiguously. Reusable for the other replaceable lists (subscriptions/blocks) that have the same latent collision. Tests: tests/replaceable-created-at.test.mjs (5 — same-second bump, clock-ahead, no-prior, rapid successive strictly-increasing, floors fractional). Lint + build green. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
1622e85d10
commit
2dc7397535
3 files changed
+89
-1
No files matched your search
@@ -0,0 +1,21 @@
|
||||
// Monotonic created_at for replaceable events (NIP-01: kind 30000 subscriptions,
|
||||
// 30015/30078 preference notes, 10000 blocks, etc). Relays keep only the newest
|
||||
// event per (kind, pubkey, d-tag) and reject/ignore an update whose created_at is
|
||||
// not STRICTLY greater than the stored one. Two quick edits within the same
|
||||
// wall-clock second otherwise collide: the second is rejected as "not newer" (the
|
||||
// client shows an error) yet lands on relays that break same-created_at ties by id
|
||||
// (so it "failed but works after refresh"). Always advance past the last-published
|
||||
// created_at to remove the collision.
|
||||
//
|
||||
// @param {number} lastCreatedAt - created_at of the last event we published (0 if none).
|
||||
// @param {number} [nowSeconds] - current unix seconds (injectable for tests).
|
||||
// @returns {number} A created_at strictly greater than lastCreatedAt.
|
||||
export function nextReplaceableCreatedAt(lastCreatedAt, nowSeconds) {
|
||||
const now = Number.isFinite(nowSeconds)
|
||||
? Math.floor(nowSeconds)
|
||||
: Math.floor(Date.now() / 1000);
|
||||
const last = Number.isFinite(lastCreatedAt) ? Math.floor(lastCreatedAt) : 0;
|
||||
return Math.max(now, last + 1);
|
||||
}
|
||||
|
||||
export default nextReplaceableCreatedAt;
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
getActiveSigner,
|
||||
} from "../nostrClientFacade.js";
|
||||
import { isSessionActor } from "../nostr/sessionActor.js";
|
||||
import { nextReplaceableCreatedAt } from "../nostr/replaceableCreatedAt.js";
|
||||
import {
|
||||
buildHashtagPreferenceEvent,
|
||||
getNostrEventSchema,
|
||||
@@ -1408,7 +1409,13 @@ class HashtagPreferencesService {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const createdAt = Math.floor(Date.now() / 1000);
|
||||
// Replaceable event: created_at MUST be strictly greater than the last one we
|
||||
// published, or relays reject the update as "not newer" and keep the old copy.
|
||||
// Two quick edits (e.g. adding a second disinterest within the same wall-clock
|
||||
// second) otherwise collide — the second errored on the client yet landed on
|
||||
// relays that resolve same-created_at ties by id, so it "failed but works after
|
||||
// refresh". Monotonic created_at removes the collision entirely.
|
||||
const createdAt = nextReplaceableCreatedAt(this.eventCreatedAt);
|
||||
const event = buildHashtagPreferenceEvent({
|
||||
pubkey: targetPubkey,
|
||||
created_at: createdAt,
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// Replaceable events (hashtag prefs, subscriptions, blocks) must publish with a
|
||||
// created_at STRICTLY greater than the last one, or relays reject the second of two
|
||||
// quick edits as "not newer" — the "added a second disinterest, it errored but
|
||||
// works after refresh" bug. nextReplaceableCreatedAt guarantees monotonicity.
|
||||
//
|
||||
// test_integrity_note:
|
||||
// change_type: ["new_tests"]
|
||||
// scenarios:
|
||||
// - id: SCN-replaceable-created-at
|
||||
// given: "the last-published created_at + the current clock"
|
||||
// when: "nextReplaceableCreatedAt computes the next created_at"
|
||||
// then: "it is strictly greater than the last, and >= now"
|
||||
// observable_outcomes:
|
||||
// - "same wall-clock second as the last -> last+1 (never equal)"
|
||||
// - "clock ahead of last -> uses now"
|
||||
// - "no prior event -> uses now"
|
||||
// - "successive rapid calls strictly increase"
|
||||
// determinism_controls:
|
||||
// - "pure function with an injected clock"
|
||||
// anti_cheat_rationale:
|
||||
// prevents: ["hard-coded return value"]
|
||||
// relaxation:
|
||||
// did_relax_any_assertion: false
|
||||
|
||||
import test from "node:test";
|
||||
import { strict as assert } from "node:assert";
|
||||
import { nextReplaceableCreatedAt } from "../js/nostr/replaceableCreatedAt.js";
|
||||
|
||||
test("same wall-clock second as the last event -> strictly greater (last+1)", () => {
|
||||
assert.equal(nextReplaceableCreatedAt(1000, 1000), 1001);
|
||||
assert.equal(nextReplaceableCreatedAt(1000, 999), 1001, "even a laggy clock advances");
|
||||
});
|
||||
|
||||
test("clock ahead of the last event -> uses the clock", () => {
|
||||
assert.equal(nextReplaceableCreatedAt(1000, 2000), 2000);
|
||||
});
|
||||
|
||||
test("no prior event -> uses the clock", () => {
|
||||
assert.equal(nextReplaceableCreatedAt(0, 500), 500);
|
||||
assert.equal(nextReplaceableCreatedAt(undefined, 500), 500);
|
||||
assert.equal(nextReplaceableCreatedAt(null, 500), 500);
|
||||
});
|
||||
|
||||
test("rapid successive edits in the same second strictly increase", () => {
|
||||
const now = 1_700_000_000;
|
||||
let last = 0;
|
||||
const seen = [];
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
last = nextReplaceableCreatedAt(last, now); // clock frozen within the same second
|
||||
seen.push(last);
|
||||
}
|
||||
for (let i = 1; i < seen.length; i += 1) {
|
||||
assert.ok(seen[i] > seen[i - 1], `edit ${i} must be strictly newer than ${i - 1}`);
|
||||
}
|
||||
assert.deepEqual(seen, [now, now + 1, now + 2, now + 3, now + 4]);
|
||||
});
|
||||
|
||||
test("floors fractional inputs (created_at is unix seconds)", () => {
|
||||
assert.equal(nextReplaceableCreatedAt(1000.9, 1000.4), 1001);
|
||||
});
|
||||
Reference in new issue
Block a user