From 2dc73975358433b5d2fc9147c8b1533f84c0b5f1 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 1 Jul 2026 19:41:00 -0400 Subject: [PATCH] [hashtags] Monotonic created_at so a rapid second edit doesn't false-error (#57) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- js/nostr/replaceableCreatedAt.js | 21 +++++++++ js/services/hashtagPreferencesService.js | 9 +++- tests/replaceable-created-at.test.mjs | 60 ++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 js/nostr/replaceableCreatedAt.js create mode 100644 tests/replaceable-created-at.test.mjs diff --git a/js/nostr/replaceableCreatedAt.js b/js/nostr/replaceableCreatedAt.js new file mode 100644 index 00000000..dac2d437 --- /dev/null +++ b/js/nostr/replaceableCreatedAt.js @@ -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; diff --git a/js/services/hashtagPreferencesService.js b/js/services/hashtagPreferencesService.js index 07ad6e30..5ef0dde5 100644 --- a/js/services/hashtagPreferencesService.js +++ b/js/services/hashtagPreferencesService.js @@ -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, diff --git a/tests/replaceable-created-at.test.mjs b/tests/replaceable-created-at.test.mjs new file mode 100644 index 00000000..ac8a4fbb --- /dev/null +++ b/tests/replaceable-created-at.test.mjs @@ -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); +});