From f8248e39c1f4f58ecc4ae02f691a69404625e3fd Mon Sep 17 00:00:00 2001 From: tcheeric Date: Sun, 4 Oct 2026 13:18:42 +0100 Subject: [PATCH 1/3] docs(mcp): mention the Lyrebird bot as a real-world user --- CHANGELOG.md | 4 ++++ docs/howto/run-the-mcp-server.md | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b479c634..c27d0aaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ The format is inspired by Keep a Changelog, and this project adheres to semantic ## [Unreleased] +### Added +- "See it in use" section in the MCP server how-to, pointing to the Lyrebird bot that posts + video clips and NIP-84 highlights through this server. + ## [2.4.1] - 2026-09-25 ### Fixed diff --git a/docs/howto/run-the-mcp-server.md b/docs/howto/run-the-mcp-server.md index f7371f0b..ef71cecb 100644 --- a/docs/howto/run-the-mcp-server.md +++ b/docs/howto/run-the-mcp-server.md @@ -286,6 +286,15 @@ mvn -pl nostr-java-mcp verify -Dexcluded.it.groups= -Dgroups=model-driven It is excluded from the ordinary build because it takes several minutes. Run it when you change a tool's name, description or schema: those are exactly the changes nothing else can catch. +## See it in use + +[Lyrebird](https://njump.me/npub12sz2fjjlfw4ydpecw5w3cvqf3ac5ca9x86ekw00lcgmq655x4taqh63tcw) +is a Nostr bot that runs on this server. It cuts clips from videos and posts them as notes with +the video hosted on Blossom, and turns passages from articles into NIP-84 highlights (kind +9802). Every post goes through the same tools described above: `nostr_blossom_upload` for the +media, then a publish under `write-policy: confirm` so a person approves each post before it +goes out. + ## Related - [Send and read NIP-17 private direct messages](private-direct-messages.md) From 474c71e72c65ea1113a7fe2c8bed387eb673d7cc Mon Sep 17 00:00:00 2001 From: tcheeric Date: Sun, 4 Oct 2026 13:28:41 +0100 Subject: [PATCH 2/3] fix(event): keep a preset created_at in GenericEvent.update() update() restamped created_at with the clock before computing the id, so callers that set created_at got an id for a different second, and NIP-59 randomised timestamps were replaced by the send time. NIP-01 derives the id from the event's own fields, so update() now only stamps the clock when created_at is unset (null or zero). Restamping on purpose is available through the new updateWithCurrentTime(). Fixes #559 --- CHANGELOG.md | 11 ++++ .../nip-17-direct-messages-spec.md | 6 ++ .../java/nostr/event/impl/GenericEvent.java | 35 +++++++--- .../event/unit/GenericEventUpdateTest.java | 66 +++++++++++++++---- 4 files changed, 97 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c27d0aaf..ebf64798 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ The format is inspired by Keep a Changelog, and this project adheres to semantic ## [Unreleased] +### Changed +- `GenericEvent.update()` now keeps a `created_at` that is already set, and stamps the current + time only when it is unset (null or zero). Code that relied on `update()` to refresh the + timestamp must call the new `updateWithCurrentTime()` instead. + +### Fixed +- `GenericEvent.update()` silently overwrote a caller-set `created_at`, so the id could be + computed for a different second than the one the caller published (relays rejected these as + `invalid: bad event id`), and NIP-59 randomised timestamps were replaced by the send time. + ([#559](https://github.com/tcheeric/nostr-java/issues/559)) + ### Added - "See it in use" section in the MCP server how-to, pointing to the Lyrebird bot that posts video clips and NIP-84 highlights through this server. diff --git a/docs/explanation/nip-17-direct-messages-spec.md b/docs/explanation/nip-17-direct-messages-spec.md index 09d6a104..11bfb264 100644 --- a/docs/explanation/nip-17-direct-messages-spec.md +++ b/docs/explanation/nip-17-direct-messages-spec.md @@ -208,6 +208,12 @@ gift-wrap code the seam it needs. The alternative, a `Clock`/`Supplier` in This is not hypothetical: the absence of this seam is the direct cause of the timestamp bug found in the imani-bridge implementation (§3.1). +**Update (issue #559).** Keeping the old `update()` behaviour turned out to be a trap of its +own: callers that set `created_at` and then called `update()` still lost it, and published ids +computed for a different second. `update()` now keeps any `created_at` already set and only +consults the clock when it is unset (null or zero). Restamping is explicit, through +`updateWithCurrentTime()`. + ### 4.2 `MessageCipher44` takes raw key bytes and prefixes `02` ```java diff --git a/nostr-java-event/src/main/java/nostr/event/impl/GenericEvent.java b/nostr-java-event/src/main/java/nostr/event/impl/GenericEvent.java index 7adf106f..747fb2cf 100644 --- a/nostr-java-event/src/main/java/nostr/event/impl/GenericEvent.java +++ b/nostr-java-event/src/main/java/nostr/event/impl/GenericEvent.java @@ -42,6 +42,9 @@ @EqualsAndHashCode public class GenericEvent implements ISignable { + /** A {@code created_at} of zero means the caller never set one, so the clock may supply it. */ + private static final long UNSET_CREATED_AT = 0L; + @EqualsAndHashCode.Include private String id; @JsonProperty("pubkey") @@ -147,19 +150,37 @@ public void addTag(BaseTag tag) { } /** - * Stamps the event with the current time, then recomputes its serialization and id. + * Recomputes this event's serialization and id, keeping any {@code created_at} already set. * - *

Use {@link #update(long)} when the timestamp is significant, such as the randomised - * {@code created_at} that NIP-59 requires on seals and gift wraps. + *

The clock is consulted only when the event has no creation time yet. A timestamp the + * caller chose, such as the randomised {@code created_at} NIP-59 requires on seals and gift + * wraps, is never replaced, so the id always matches the {@code created_at} the caller sees. + * Use {@link #updateWithCurrentTime()} to restamp deliberately. */ public void update() { + if (hasCreatedAt()) { + update(this.createdAt); + } else { + updateWithCurrentTime(); + } + } + + /** + * Stamps the event with the current time, replacing any existing {@code created_at}, then + * recomputes its serialization and id. + */ + public void updateWithCurrentTime() { update(Instant.now().getEpochSecond()); } + private boolean hasCreatedAt() { + return this.createdAt != null && this.createdAt != UNSET_CREATED_AT; + } + /** * Recomputes this event's serialization and id against the supplied creation time. * - *

Unlike {@link #update()} this does not consult the clock, so a deliberately chosen + *

This does not consult the clock, so a deliberately chosen * {@code created_at} survives id computation. NIP-59 requires seals and gift wraps to carry * timestamps randomised into the past to defeat time-correlation analysis, which is * impossible if computing the id resets the timestamp. @@ -276,11 +297,7 @@ public Consumer getSignatureConsumer() { @Transient @Override public Supplier getByteArraySupplier() { - if (this.createdAt != null) { - this.update(this.createdAt); - } else { - this.update(); - } + this.update(); if (log.isTraceEnabled()) { log.trace("Serialized event: {}", new String(this.get_serializedEvent())); } diff --git a/nostr-java-event/src/test/java/nostr/event/unit/GenericEventUpdateTest.java b/nostr-java-event/src/test/java/nostr/event/unit/GenericEventUpdateTest.java index e9a4ba98..505553f5 100644 --- a/nostr-java-event/src/test/java/nostr/event/unit/GenericEventUpdateTest.java +++ b/nostr-java-event/src/test/java/nostr/event/unit/GenericEventUpdateTest.java @@ -7,7 +7,10 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; import java.time.Instant; +import java.util.HexFormat; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -75,13 +78,10 @@ void computesSameIdForSameCreatedAt() { assertEquals(first.getId(), second.getId()); } - /** - * The no-argument overload still stamps the event with the current time, so existing callers - * are unaffected by the new seam. - */ + /** An event with no creation time is stamped with the current time. */ @Test - @DisplayName("still stamps the current time when no timestamp is supplied") - void stampsCurrentTimeWithoutArgument() { + @DisplayName("stamps the current time when no created_at is set") + void stampsCurrentTimeWhenUnset() { long before = Instant.now().getEpochSecond(); GenericEvent event = anEvent(); @@ -91,21 +91,63 @@ void stampsCurrentTimeWithoutArgument() { assertTrue(event.getCreatedAt() >= before && event.getCreatedAt() <= after); } + /** A created_at of zero counts as unset, so it is replaced with the current time. */ + @Test + @DisplayName("treats a zero created_at as unset") + void treatsZeroCreatedAtAsUnset() { + long before = Instant.now().getEpochSecond(); + GenericEvent event = anEvent(); + event.setCreatedAt(0L); + + event.update(); + + assertTrue(event.getCreatedAt() >= before); + } + /** - * A previously chosen timestamp is discarded by the no-argument overload. This is the - * behaviour that silently defeats gift-wrap privacy, so it is pinned here to document why - * {@code update(long)} must be used for seals and wraps. + * Regression for issue #559: a created_at the caller set survives {@code update()}. Losing it + * produced ids for a different second than the one the caller published, and erased the + * randomised timestamps NIP-59 seals and gift wraps depend on. */ @Test - @DisplayName("overwrites a preset created_at when no timestamp is supplied") - void overwritesPresetCreatedAtWithoutArgument() { + @DisplayName("keeps a preset created_at when no timestamp is supplied") + void keepsPresetCreatedAtWithoutArgument() { long twoDaysAgo = Instant.now().minusSeconds(2 * 24 * 60 * 60).getEpochSecond(); GenericEvent event = anEvent(); event.setCreatedAt(twoDaysAgo); event.update(); - assertNotEquals(twoDaysAgo, event.getCreatedAt()); + assertEquals(twoDaysAgo, event.getCreatedAt()); + } + + /** The id computed by {@code update()} is the NIP-01 hash for the preset created_at. */ + @Test + @DisplayName("computes the NIP-01 id for a preset created_at") + void computesNip01IdForPresetCreatedAt() throws Exception { + GenericEvent event = anEvent(); + event.setCreatedAt(1_700_000_000L); + + event.update(); + + String canonical = + "[0,\"" + AUTHOR + "\",1700000000," + Kinds.SEAL + ",[],\"encrypted-payload\"]"; + byte[] digest = + MessageDigest.getInstance("SHA-256").digest(canonical.getBytes(StandardCharsets.UTF_8)); + assertEquals(HexFormat.of().formatHex(digest), event.getId()); + } + + /** Restamping is still available, but only when asked for by name. */ + @Test + @DisplayName("replaces a preset created_at when restamping explicitly") + void replacesPresetCreatedAtWhenRestampingExplicitly() { + long twoDaysAgo = Instant.now().minusSeconds(2 * 24 * 60 * 60).getEpochSecond(); + GenericEvent event = anEvent(); + event.setCreatedAt(twoDaysAgo); + + event.updateWithCurrentTime(); + + assertTrue(event.getCreatedAt() >= Instant.now().getEpochSecond() - 1); } /** Tags are included in the serialization that backs the id. */ From 9118ec98ac0b34bc4ee867d39080c8f21824c756 Mon Sep 17 00:00:00 2001 From: tcheeric Date: Sun, 4 Oct 2026 13:37:51 +0100 Subject: [PATCH 3/3] chore(release): bump to 2.4.2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - nostr-java (all modules): 2.4.1 → 2.4.2 (patch) [maven] Fixed: GenericEvent.update() overwrote a caller-set created_at, so ids could be computed for a different second (#559). Added: GenericEvent.updateWithCurrentTime(), and the Lyrebird mention in the MCP server how-to. --- CHANGELOG.md | 11 ++++++----- docs/howto/multi-relay-publishing.md | 2 +- nostr-java-api/pom.xml | 2 +- nostr-java-client/pom.xml | 2 +- nostr-java-core/pom.xml | 2 +- nostr-java-event/pom.xml | 2 +- nostr-java-identity/pom.xml | 2 +- nostr-java-mcp/pom.xml | 2 +- pom.xml | 2 +- 9 files changed, 14 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ebf64798..f4d20ac5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,18 +6,19 @@ The format is inspired by Keep a Changelog, and this project adheres to semantic ## [Unreleased] -### Changed -- `GenericEvent.update()` now keeps a `created_at` that is already set, and stamps the current - time only when it is unset (null or zero). Code that relied on `update()` to refresh the - timestamp must call the new `updateWithCurrentTime()` instead. +## [2.4.2] - 2026-10-04 ### Fixed - `GenericEvent.update()` silently overwrote a caller-set `created_at`, so the id could be computed for a different second than the one the caller published (relays rejected these as `invalid: bad event id`), and NIP-59 randomised timestamps were replaced by the send time. - ([#559](https://github.com/tcheeric/nostr-java/issues/559)) + `update()` now keeps a `created_at` that is already set, as NIP-01 requires the id to be + derived from the event's own fields, and stamps the current time only when it is unset + (null or zero). ([#559](https://github.com/tcheeric/nostr-java/issues/559)) ### Added +- `GenericEvent.updateWithCurrentTime()`, for callers that deliberately want to restamp an + event with the current time. - "See it in use" section in the MCP server how-to, pointing to the Lyrebird bot that posts video clips and NIP-84 highlights through this server. diff --git a/docs/howto/multi-relay-publishing.md b/docs/howto/multi-relay-publishing.md index b30e2e2f..7254ac59 100644 --- a/docs/howto/multi-relay-publishing.md +++ b/docs/howto/multi-relay-publishing.md @@ -10,7 +10,7 @@ send a private direct message. xyz.tcheeric nostr-java-api - 2.4.1 + 2.4.2 ``` diff --git a/nostr-java-api/pom.xml b/nostr-java-api/pom.xml index 9f780eb6..b664d4ad 100644 --- a/nostr-java-api/pom.xml +++ b/nostr-java-api/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 2.4.1 + 2.4.2 ../pom.xml diff --git a/nostr-java-client/pom.xml b/nostr-java-client/pom.xml index 70729801..33b141d6 100644 --- a/nostr-java-client/pom.xml +++ b/nostr-java-client/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 2.4.1 + 2.4.2 ../pom.xml diff --git a/nostr-java-core/pom.xml b/nostr-java-core/pom.xml index 176578e4..cf428403 100644 --- a/nostr-java-core/pom.xml +++ b/nostr-java-core/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 2.4.1 + 2.4.2 ../pom.xml diff --git a/nostr-java-event/pom.xml b/nostr-java-event/pom.xml index 5dbe5df2..1631f5a1 100644 --- a/nostr-java-event/pom.xml +++ b/nostr-java-event/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 2.4.1 + 2.4.2 ../pom.xml diff --git a/nostr-java-identity/pom.xml b/nostr-java-identity/pom.xml index b0ae7b48..7560da8d 100644 --- a/nostr-java-identity/pom.xml +++ b/nostr-java-identity/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 2.4.1 + 2.4.2 ../pom.xml diff --git a/nostr-java-mcp/pom.xml b/nostr-java-mcp/pom.xml index a4621c9f..e4a8f6e5 100644 --- a/nostr-java-mcp/pom.xml +++ b/nostr-java-mcp/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 2.4.1 + 2.4.2 ../pom.xml diff --git a/pom.xml b/pom.xml index e7e31593..c8d8ace1 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ xyz.tcheeric nostr-java - 2.4.1 + 2.4.2 pom nostr-java