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); +});