[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:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-07-01 19:41:00 -04:00
1 parent 1622e85d10
commit 2dc7397535
3 files changed
+89 -1

No files matched your search

+21
View File
@@ -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;
+8 -1
View File
@@ -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,
+60
View File
@@ -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);
});