From 0fae444aeb45db08546d4186edd5ed7ac7fb196c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 15 Dec 2025 14:25:01 +0000 Subject: [PATCH 01/21] chore(deps): bump actions/upload-artifact from 4 to 6 Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 6. - [Release notes](https://github.com/actions/upload-artifact/releases) - [Commits](https://github.com/actions/upload-artifact/compare/v4...v6) --- updated-dependencies: - dependency-name: actions/upload-artifact dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 4 ++-- .github/workflows/release.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d9355899..a2c5450d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,7 +37,7 @@ jobs: - name: Upload test reports and coverage (if present) if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: reports-jdk-${{ matrix.java-version }} if-no-files-found: ignore @@ -68,7 +68,7 @@ jobs: - name: Upload IT reports and coverage if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: reports-integration if-no-files-found: ignore diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e36cb793..d8431492 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -91,7 +91,7 @@ jobs: - name: Upload coverage reports if: always() - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: reports-release if-no-files-found: ignore From a6035e33ca6d2c0787127c9a0b8a9f335fd32aa1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 22 Dec 2025 14:23:19 +0000 Subject: [PATCH 02/21] chore(deps): bump JetBrains/qodana-action from 2025.2 to 2025.3 Bumps [JetBrains/qodana-action](https://github.com/jetbrains/qodana-action) from 2025.2 to 2025.3. - [Release notes](https://github.com/jetbrains/qodana-action/releases) - [Commits](https://github.com/jetbrains/qodana-action/compare/v2025.2...v2025.3) --- updated-dependencies: - dependency-name: JetBrains/qodana-action dependency-version: '2025.3' dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/qodana_code_quality.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/qodana_code_quality.yml b/.github/workflows/qodana_code_quality.yml index 525a255b..ba3102b3 100644 --- a/.github/workflows/qodana_code_quality.yml +++ b/.github/workflows/qodana_code_quality.yml @@ -20,7 +20,7 @@ jobs: ref: ${{ github.event.pull_request.head.sha }} # to check out the actual pull request commit, not the merge commit fetch-depth: 0 # a full history is required for pull request analysis - name: 'Qodana Scan' - uses: JetBrains/qodana-action@v2025.2 + uses: JetBrains/qodana-action@v2025.3 with: pr-mode: false env: From 52fc2d9704128be7c813ab83940dba9e699faf06 Mon Sep 17 00:00:00 2001 From: tcheeric Date: Thu, 25 Dec 2025 21:08:26 +0000 Subject: [PATCH 03/21] docs: remove AGENTS.md and CLAUDE.md files Deleted outdated AGENTS.md and CLAUDE.md documentation files no longer relevant to the repository structure and guidelines. --- AGENTS.md | 170 ---------------------------------------------------- CLAUDE.md | 174 ------------------------------------------------------ 2 files changed, 344 deletions(-) delete mode 100644 AGENTS.md delete mode 100644 CLAUDE.md diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 3f4d9d33..00000000 --- a/AGENTS.md +++ /dev/null @@ -1,170 +0,0 @@ -# Repo Guidelines - -## NIPs - -- https://github.com/nostr-protocol/nips/blob/master/01.md -- https://github.com/nostr-protocol/nips/blob/master/02.md -- https://github.com/nostr-protocol/nips/blob/master/03.md -- https://github.com/nostr-protocol/nips/blob/master/04.md -- https://github.com/nostr-protocol/nips/blob/master/05.md -- https://github.com/nostr-protocol/nips/blob/master/06.md -- https://github.com/nostr-protocol/nips/blob/master/07.md -- https://github.com/nostr-protocol/nips/blob/master/08.md -- https://github.com/nostr-protocol/nips/blob/master/09.md -- https://github.com/nostr-protocol/nips/blob/master/10.md -- https://github.com/nostr-protocol/nips/blob/master/11.md -- https://github.com/nostr-protocol/nips/blob/master/12.md -- https://github.com/nostr-protocol/nips/blob/master/13.md -- https://github.com/nostr-protocol/nips/blob/master/14.md -- https://github.com/nostr-protocol/nips/blob/master/15.md -- https://github.com/nostr-protocol/nips/blob/master/16.md -- https://github.com/nostr-protocol/nips/blob/master/17.md -- https://github.com/nostr-protocol/nips/blob/master/18.md -- https://github.com/nostr-protocol/nips/blob/master/19.md -- https://github.com/nostr-protocol/nips/blob/master/20.md -- https://github.com/nostr-protocol/nips/blob/master/21.md -- https://github.com/nostr-protocol/nips/blob/master/22.md -- https://github.com/nostr-protocol/nips/blob/master/23.md -- https://github.com/nostr-protocol/nips/blob/master/24.md -- https://github.com/nostr-protocol/nips/blob/master/25.md -- https://github.com/nostr-protocol/nips/blob/master/26.md -- https://github.com/nostr-protocol/nips/blob/master/27.md -- https://github.com/nostr-protocol/nips/blob/master/28.md -- https://github.com/nostr-protocol/nips/blob/master/29.md -- https://github.com/nostr-protocol/nips/blob/master/30.md -- https://github.com/nostr-protocol/nips/blob/master/31.md -- https://github.com/nostr-protocol/nips/blob/master/32.md -- https://github.com/nostr-protocol/nips/blob/master/33.md -- https://github.com/nostr-protocol/nips/blob/master/34.md -- https://github.com/nostr-protocol/nips/blob/master/35.md -- https://github.com/nostr-protocol/nips/blob/master/36.md -- https://github.com/nostr-protocol/nips/blob/master/37.md -- https://github.com/nostr-protocol/nips/blob/master/38.md -- https://github.com/nostr-protocol/nips/blob/master/39.md -- https://github.com/nostr-protocol/nips/blob/master/40.md -- https://github.com/nostr-protocol/nips/blob/master/42.md -- https://github.com/nostr-protocol/nips/blob/master/44.md -- https://github.com/nostr-protocol/nips/blob/master/45.md -- https://github.com/nostr-protocol/nips/blob/master/46.md -- https://github.com/nostr-protocol/nips/blob/master/47.md -- https://github.com/nostr-protocol/nips/blob/master/48.md -- https://github.com/nostr-protocol/nips/blob/master/49.md -- https://github.com/nostr-protocol/nips/blob/master/50.md -- https://github.com/nostr-protocol/nips/blob/master/51.md -- https://github.com/nostr-protocol/nips/blob/master/52.md -- https://github.com/nostr-protocol/nips/blob/master/53.md -- https://github.com/nostr-protocol/nips/blob/master/54.md -- https://github.com/nostr-protocol/nips/blob/master/55.md -- https://github.com/nostr-protocol/nips/blob/master/56.md -- https://github.com/nostr-protocol/nips/blob/master/57.md -- https://github.com/nostr-protocol/nips/blob/master/58.md -- https://github.com/nostr-protocol/nips/blob/master/59.md -- https://github.com/nostr-protocol/nips/blob/master/60.md -- https://github.com/nostr-protocol/nips/blob/master/61.md -- https://github.com/nostr-protocol/nips/blob/master/62.md -- https://github.com/nostr-protocol/nips/blob/master/64.md -- https://github.com/nostr-protocol/nips/blob/master/65.md -- https://github.com/nostr-protocol/nips/blob/master/66.md -- https://github.com/nostr-protocol/nips/blob/master/68.md -- https://github.com/nostr-protocol/nips/blob/master/69.md -- https://github.com/nostr-protocol/nips/blob/master/70.md -- https://github.com/nostr-protocol/nips/blob/master/71.md -- https://github.com/nostr-protocol/nips/blob/master/72.md -- https://github.com/nostr-protocol/nips/blob/master/73.md -- https://github.com/nostr-protocol/nips/blob/master/75.md -- https://github.com/nostr-protocol/nips/blob/master/77.md -- https://github.com/nostr-protocol/nips/blob/master/78.md -- https://github.com/nostr-protocol/nips/blob/master/7D.md -- https://github.com/nostr-protocol/nips/blob/master/84.md -- https://github.com/nostr-protocol/nips/blob/master/86.md -- https://github.com/nostr-protocol/nips/blob/master/87.md -- https://github.com/nostr-protocol/nips/blob/master/88.md -- https://github.com/nostr-protocol/nips/blob/master/89.md -- https://github.com/nostr-protocol/nips/blob/master/90.md -- https://github.com/nostr-protocol/nips/blob/master/92.md -- https://github.com/nostr-protocol/nips/blob/master/94.md -- https://github.com/nostr-protocol/nips/blob/master/96.md -- https://github.com/nostr-protocol/nips/blob/master/98.md -- https://github.com/nostr-protocol/nips/blob/master/99.md -- https://github.com/nostr-protocol/nips/blob/master/A0.md -- https://github.com/nostr-protocol/nips/blob/master/B0.md -- https://github.com/nostr-protocol/nips/blob/master/B7.md -- https://github.com/nostr-protocol/nips/blob/master/C0.md -- https://github.com/nostr-protocol/nips/blob/master/C7.md - -## Description -nostr-java is a java implementation of the nostr protocol. The specification is available on github, here: https://github.com/nostr-protocol/nips -The URL format for the NIPs is https://github.com/nostr-protocol/nips/blob/master/XX.md where XX is the NIP number. For example, the specification for NIP-01 is available at the URL https://github.com/nostr-protocol/nips/blob/master/01.md etc. - - -## Coding -- When writing code, follow the "Clean Code" principles: - - [Clean Code](https://dev.398ja.xyz/books/Clean_Architecture.pdf) - - Relevant chapters: 2, 3, 4, 7, 10, 17 - - [Clean Architecture](https://dev.398ja.xyz/books/Clean_Code.pdf) - - Relevant chapters: All chapters in part III and IV, 7-14. - - [Design Patterns](https://github.com/iluwatar/java-design-patterns) - - Follow design patterns as described in the book, whenever possible. -- When commiting code, follow the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) specification. -- When adding new features, ensure they are compliant with the Cashu specification (NUTs) provided above. -- Make use of the lombok library to reduce boilerplate code. - -## Documentation - -- When generating documentation: - - Follow the Diátaxis framework and classify each document as a tutorial, how-to guide, reference, or explanation. - - Place new Markdown files under `docs/
` matching the chosen category. - - Start each document with a top-level `#` heading and a short introduction that states the purpose. - - Link the document from `docs/README.md` in the corresponding section. - - Use relative links to reference other documents and keep code snippets minimal and tested. - - Consult the following resources on Diátaxis for guidance: - - https://github.blog/developer-skills/documentation-done-right-a-developers-guide/ - - https://diataxis.fr/ - - https://diataxis.fr/start-here/ - - https://diataxis.fr/how-to-use-diataxis/ - - https://diataxis.fr/tutorials/ - - https://diataxis.fr/how-to-guides/ - - https://diataxis.fr/tutorials-how-to/ - - https://diataxis.fr/quality/ - - https://diataxis.fr/complex-hierarchies/ - - https://diataxis.fr/compass/ - -## Testing - -- Always run `mvn -q verify` from the repository root before committing your changes. -- Include the command's output in the PR description. -- If tests fail due to dependency or network issues, mention this in the PR. -- Update the documentation files if you add or modify features. -- Update the `pom.xml` file for new modules or dependencies, ensuring compatibility with Java 21. -- Verify new Dockerfiles or `docker-compose.yml` files by running `docker-compose build`. -- Document new REST endpoints in the API documentation and ensure they are tested. -- Add unit tests for new functionality, covering edge cases. Follow "Clean Code" principles on unit tests, as described in the "Clean Code" book (Chapter 9). -- Ensure modifications to existing code do not break functionality and pass all tests. -- Add integration tests for new features to verify end-to-end functionality. -- Ensure new dependencies or configurations do not introduce security vulnerabilities. -- Add a comment on top of every test method to describe the test in plain English. - -## Pull Requests - -- Always follow the repository's PR submission guidelines and use the PR template located at `.github/pull_request_template.md`. -- Summarize the changes made and describe how they were tested. -- Include any limitations or known issues in the description. -- Ensure all new features are compliant with the Nostr specification (NIPs) provided above. - -## Versioning - -- Follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) for all releases. -- Update the version in the parent `pom.xml` and all module POMs when preparing a release. -- Use conventional commit types to signal version bumps (fix → patch, feat → minor, BREAKING CHANGE → major). - -## Changelog Maintenance - -- **Always update `CHANGELOG.md`** after any version change or significant code modification. -- Follow the [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) format: - - Group changes under: `Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, `Security` - - List versions in reverse chronological order (newest first) - - Use `[Unreleased]` section for changes not yet in a release - - Include the release date in ISO format: `## [1.0.0] - 2025-12-17` -- Each entry should be a concise, human-readable description of the change -- Reference related issues or PRs where applicable -- Update the changelog in the same commit as the version bump when possible diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index e32d5285..00000000 --- a/CLAUDE.md +++ /dev/null @@ -1,174 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -`nostr-java` is a Java SDK for the Nostr protocol. It provides utilities for creating, signing, and publishing Nostr events to relays. The project implements 20+ Nostr Implementation Possibilities (NIPs). - -- **Language**: Java 21+ -- **Build Tool**: Maven -- **Architecture**: Multi-module Maven project with 9 modules - -## Module Architecture - -The codebase follows a layered dependency structure. Understanding this hierarchy is essential for making changes: - -1. **nostr-java-util** – Foundation utilities (no dependencies on other modules) -2. **nostr-java-crypto** – BIP340 Schnorr signatures (depends on util) -3. **nostr-java-base** – Common model classes (depends on crypto, util) -4. **nostr-java-event** – Event and tag definitions (depends on base, crypto, util) -5. **nostr-java-id** – Identity and key handling (depends on base, crypto) -6. **nostr-java-encryption** – Message encryption (depends on base, crypto, id) -7. **nostr-java-client** – WebSocket relay client (depends on event, base) -8. **nostr-java-api** – High-level API (depends on all above) -9. **nostr-java-examples** – Sample applications (depends on api) - -**Key principle**: Lower-level modules cannot depend on higher-level ones. When adding features, place code at the lowest appropriate level. - -## Common Development Commands - -### Building and Testing - -```bash -# Run all unit tests (no Docker required) -mvn clean test - -# Run integration tests (requires Docker for Testcontainers) -mvn clean verify - -# Run integration tests with verbose output -mvn -q verify - -# Install artifacts without tests -mvn install -Dmaven.test.skip=true - -# Run a specific test class -mvn -q test -Dtest=GenericEventBuilderTest - -# Run a specific test method -mvn -q test -Dtest=GenericEventBuilderTest#testSpecificMethod -``` - -### Code Quality - -```bash -# Verify code quality and run all checks -mvn -q verify - -# Generate code coverage report (Jacoco) -mvn verify -# Reports: target/site/jacoco/index.html in each module -``` - -## Key Architectural Patterns - -### Event System - -- **GenericEvent** (`nostr-java-event/src/main/java/nostr/event/impl/GenericEvent.java`) is the core event class -- Events can be built using: - - Direct constructors with `PublicKey` and `Kind`/`Integer` - - Static `GenericEvent.builder()` for flexible construction -- All events must be signed before sending to relays -- Events support both NIP-defined kinds (via `Kind` enum) and custom kinds (via `Integer`) - -### Client Architecture - -Two WebSocket client implementations: - -1. **StandardWebSocketClient** – Blocking, waits for relay responses with configurable timeout -2. **NostrSpringWebSocketClient** – Non-blocking with Spring WebSocket and retry support (3 retries, exponential backoff from 500ms) - -Configuration properties: -- `nostr.websocket.await-timeout-ms=60000` -- `nostr.websocket.poll-interval-ms=500` - -### Tag System - -- Tags are represented by `BaseTag` and subclasses -- Custom tags can be registered via `TagRegistry` -- Serialization/deserialization handled by Jackson with custom serializers in `nostr.event.json.serializer` - -### Identity and Signing - -- `Identity` class manages key pairs -- Events implement `ISignable` interface -- Signing uses Schnorr signatures (BIP340) -- Public keys use Bech32 encoding (npub prefix) - -## NIPs Implementation - -The codebase implements NIPs through dedicated classes in `nostr-java-api`: -- NIP classes (e.g., `NIP01`, `NIP04`, `NIP25`) provide builder methods and utilities -- Event implementations in `nostr-java-event/src/main/java/nostr/event/impl/` -- Refer to `.github/copilot-instructions.md` for the full NIP specification links - -When implementing new NIP support: -1. Add event class in `nostr-java-event` if needed -2. Create NIP helper class in `nostr-java-api` -3. Add tests in both modules -4. Update README.md with NIP reference -5. Add example in `nostr-java-examples` - -## Testing Strategy - -- **Unit tests** (`*Test.java`): No external dependencies, use mocks -- **Integration tests** (`*IT.java`): Use Testcontainers to start `nostr-rs-relay` -- Relay container image can be overridden in `src/test/resources/relay-container.properties` -- Integration tests may be retried once on failure (configured in failsafe plugin) - -## Code Standards - -- **Commit messages**: Must follow conventional commits format: `type(scope): description` - - Allowed types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert` - - See `commit_instructions.md` for full guidelines -- **PR target**: All PRs should target the `develop` branch -- **Code formatting**: Google Java Format (enforced by CI) -- **Test coverage**: Jacoco generates reports (enforced by CI) -- **Required**: All changes must include unit tests and documentation updates - -## Dependency Management - -- **BOM**: `nostr-java-bom` (version 1.1.1) manages all dependency versions -- Root `pom.xml` includes temporary module version overrides until next BOM release -- Never add version numbers to dependencies in child modules – let the BOM manage versions - -## Documentation - -Comprehensive documentation in `docs/`: -- `docs/GETTING_STARTED.md` – Installation and setup -- `docs/howto/use-nostr-java-api.md` – API usage guide -- `docs/howto/streaming-subscriptions.md` – Subscription management -- `docs/howto/custom-events.md` – Creating custom event types -- `docs/reference/nostr-java-api.md` – API reference -- `docs/CODEBASE_OVERVIEW.md` – Module layout and build instructions - -## Common Patterns and Gotchas - -### Event Building -```java -// Using builder for custom kinds -GenericEvent event = GenericEvent.builder() - .kind(customKindInteger) - .content("content") - .pubKey(publicKey) - .build(); - -// Using constructor for standard kinds -GenericEvent event = new GenericEvent(pubKey, Kind.TEXT_NOTE); -``` - -### Signing and Sending -```java -// Sign and send pattern -EventNostr nostr = new NIP01(identity); -nostr.createTextNote("Hello Nostr!") - .sign() - .send(relays); -``` - -### Custom Tags -Register custom tags in `TagRegistry` before deserializing events that contain them. - -### WebSocket Sessions -Spring WebSocket client maintains persistent connections. Always close subscriptions properly to avoid resource leaks. From fb32af9a5a4dcf1d2bf07c6280cc93b7e41b28ae Mon Sep 17 00:00:00 2001 From: tcheeric Date: Fri, 26 Dec 2025 11:17:01 +0000 Subject: [PATCH 04/21] fix(nip44): use HKDF instead of PBKDF2 for key derivation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The NIP-44 specification requires HKDF for key derivation: - conversation_key = HKDF-Extract(salt="nip44-v2", ikm=shared_x) - keys = HKDF-Expand(prk=conversation_key, info=nonce, L=76) The previous implementation incorrectly used PBKDF2WithHmacSHA256, which caused decryption failures when interoperating with other NIP-44 implementations (e.g., nostr-tools in JavaScript). Changes: - getConversationKey: Use HKDFBytesGenerator with salt for extract - getMessageKeys: Use HKDFParameters.skipExtractParameters for expand - Remove unused PBKDF2 imports and constants - Simplify getConversationKey signature (no longer throws checked exceptions) This fix enables DM interoperability between Java backend and JavaScript frontend implementations. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../nostr/crypto/nip44/EncryptedPayloads.java | 44 +++++++------------ .../nostr/encryption/MessageCipher44.java | 11 +---- 2 files changed, 19 insertions(+), 36 deletions(-) diff --git a/nostr-java-crypto/src/main/java/nostr/crypto/nip44/EncryptedPayloads.java b/nostr-java-crypto/src/main/java/nostr/crypto/nip44/EncryptedPayloads.java index 8d05d3ca..399ccb1e 100644 --- a/nostr-java-crypto/src/main/java/nostr/crypto/nip44/EncryptedPayloads.java +++ b/nostr-java-crypto/src/main/java/nostr/crypto/nip44/EncryptedPayloads.java @@ -2,9 +2,7 @@ import javax.crypto.Cipher; import javax.crypto.Mac; -import javax.crypto.SecretKeyFactory; import javax.crypto.spec.IvParameterSpec; -import javax.crypto.spec.PBEKeySpec; import javax.crypto.spec.SecretKeySpec; import lombok.NonNull; import lombok.extern.slf4j.Slf4j; @@ -23,9 +21,6 @@ import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.security.spec.InvalidKeySpecException; -import java.security.spec.KeySpec; import java.util.Arrays; import java.util.Base64; @@ -98,8 +93,7 @@ public static String decrypt(String payload, byte[] conversationKey) throws Exce return EncryptedPayloads.unpad(paddedPlaintext); } - public static byte[] getConversationKey(String privkeyA, String pubkeyB) - throws NoSuchAlgorithmException, InvalidKeySpecException { + public static byte[] getConversationKey(String privkeyA, String pubkeyB) { // Get the ECNamedCurveParameterSpec for the secp256k1 curve ECNamedCurveParameterSpec ecSpec = ECNamedCurveTable.getParameterSpec("secp256k1"); @@ -125,15 +119,17 @@ public static byte[] getConversationKey(String privkeyA, String pubkeyB) new FixedPointCombMultiplier() .multiply(publicKeyParameters.getQ(), privateKeyParameters.getD()); - // The result of the point multiplication is the shared secret + // The result of the point multiplication is the shared secret (x-coordinate) byte[] sharedX = pointQ.normalize().getAffineXCoord().getEncoded(); - // Derive the key using HKDF - char[] sharedXChars = new String(sharedX, StandardCharsets.UTF_8).toCharArray(); - PBEKeySpec keySpec = new PBEKeySpec(sharedXChars, "nip44-v2".getBytes(), 65536, 256); - SecretKeyFactory factory = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256"); + // NIP-44: conversation_key = HKDF-Extract(salt="nip44-v2", ikm=shared_x) + byte[] salt = "nip44-v2".getBytes(StandardCharsets.UTF_8); + HKDFBytesGenerator hkdf = new HKDFBytesGenerator(new SHA256Digest()); + hkdf.init(new HKDFParameters(sharedX, salt, null)); + byte[] conversationKey = new byte[32]; + hkdf.generateBytes(conversationKey, 0, 32); - return factory.generateSecret(keySpec).getEncoded(); + return conversationKey; } public static byte[] hexStringToByteArray(String s) { @@ -237,17 +233,14 @@ private static byte[][] getMessageKeys(byte[] conversationKey, byte[] nonce) thr throw new Exception("Invalid nonce length"); } - SecretKeyFactory skf = SecretKeyFactory.getInstance(Constants.KEY_DERIVATION_ALGORITHM); - KeySpec spec = - new PBEKeySpec( - new String(conversationKey, StandardCharsets.UTF_8).toCharArray(), - nonce, - 65536, - (Constants.CHACHA_KEY_LENGTH - + Constants.CHACHA_NONCE_LENGTH - + Constants.HMAC_KEY_LENGTH) - * 8); - byte[] keys = skf.generateSecret(spec).getEncoded(); + // NIP-44: keys = HKDF-Expand(prk=conversation_key, info=nonce, L=76) + // Output: chacha_key (32) || chacha_nonce (12) || hmac_key (32) = 76 bytes + int keyLength = Constants.CHACHA_KEY_LENGTH + Constants.CHACHA_NONCE_LENGTH + Constants.HMAC_KEY_LENGTH; + HKDFBytesGenerator hkdf = new HKDFBytesGenerator(new SHA256Digest()); + // For HKDF-Expand only: pass conversationKey as IKM with empty salt, and nonce as info + hkdf.init(HKDFParameters.skipExtractParameters(conversationKey, nonce)); + byte[] keys = new byte[keyLength]; + hkdf.generateBytes(keys, 0, keyLength); byte[] chachaKey = Arrays.copyOfRange(keys, 0, Constants.CHACHA_KEY_LENGTH); byte[] chachaNonce = @@ -276,11 +269,8 @@ private static byte[] concat(byte[]... arrays) { private static class Constants { public static final int MIN_PLAINTEXT_SIZE = 1; public static final int MAX_PLAINTEXT_SIZE = 65535; - public static final String SALT_PREFIX = "nip44-v2"; private static final String ENCRYPTION_ALGORITHM = "ChaCha20"; private static final String HMAC_ALGORITHM = "HmacSHA256"; - private static final String KEY_DERIVATION_ALGORITHM = "PBKDF2WithHmacSHA256"; - // private static final String CHARACTER_ENCODING = StandardCharsets.UTF_8.name(); private static final int CONVERSATION_KEY_LENGTH = 32; private static final int NONCE_LENGTH = 32; private static final int HMAC_KEY_LENGTH = 32; diff --git a/nostr-java-encryption/src/main/java/nostr/encryption/MessageCipher44.java b/nostr-java-encryption/src/main/java/nostr/encryption/MessageCipher44.java index c393131f..bd1fbc4a 100644 --- a/nostr-java-encryption/src/main/java/nostr/encryption/MessageCipher44.java +++ b/nostr-java-encryption/src/main/java/nostr/encryption/MessageCipher44.java @@ -6,9 +6,6 @@ import nostr.crypto.nip44.EncryptedPayloads; import nostr.util.NostrUtil; -import java.security.NoSuchAlgorithmException; -import java.security.spec.InvalidKeySpecException; - @Data @AllArgsConstructor public class MessageCipher44 implements MessageCipher { @@ -40,12 +37,8 @@ public String decrypt(@NonNull String payload) { } private byte[] getConversationKey() { - try { - return EncryptedPayloads.getConversationKey( - NostrUtil.bytesToHex(senderPrivateKey), "02" + NostrUtil.bytesToHex(recipientPublicKey)); - } catch (NoSuchAlgorithmException | InvalidKeySpecException e) { - throw new RuntimeException(e); - } + return EncryptedPayloads.getConversationKey( + NostrUtil.bytesToHex(senderPrivateKey), "02" + NostrUtil.bytesToHex(recipientPublicKey)); } private byte[] generateNonce() { From 12f4cb18c2f601c8d982501fdf7b332aa1443ba3 Mon Sep 17 00:00:00 2001 From: tcheeric Date: Fri, 26 Dec 2025 11:19:07 +0000 Subject: [PATCH 05/21] chore(release): bump version to 1.2.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Breaking change: NIP-44 now uses correct HKDF key derivation. Ciphertext encrypted with previous versions will not decrypt. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- AGENTS.md | 170 +++++++++++++++++ CLAUDE.md | 174 ++++++++++++++++++ nostr-java-api/pom.xml | 2 +- nostr-java-api/src/test/resources/strfry.conf | 75 ++++++++ nostr-java-base/pom.xml | 2 +- nostr-java-client/pom.xml | 2 +- nostr-java-crypto/pom.xml | 2 +- nostr-java-encryption/pom.xml | 2 +- nostr-java-event/pom.xml | 2 +- nostr-java-examples/pom.xml | 2 +- nostr-java-id/pom.xml | 2 +- nostr-java-util/pom.xml | 2 +- pom.xml | 2 +- 13 files changed, 429 insertions(+), 10 deletions(-) create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 nostr-java-api/src/test/resources/strfry.conf diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..3f4d9d33 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,170 @@ +# Repo Guidelines + +## NIPs + +- https://github.com/nostr-protocol/nips/blob/master/01.md +- https://github.com/nostr-protocol/nips/blob/master/02.md +- https://github.com/nostr-protocol/nips/blob/master/03.md +- https://github.com/nostr-protocol/nips/blob/master/04.md +- https://github.com/nostr-protocol/nips/blob/master/05.md +- https://github.com/nostr-protocol/nips/blob/master/06.md +- https://github.com/nostr-protocol/nips/blob/master/07.md +- https://github.com/nostr-protocol/nips/blob/master/08.md +- https://github.com/nostr-protocol/nips/blob/master/09.md +- https://github.com/nostr-protocol/nips/blob/master/10.md +- https://github.com/nostr-protocol/nips/blob/master/11.md +- https://github.com/nostr-protocol/nips/blob/master/12.md +- https://github.com/nostr-protocol/nips/blob/master/13.md +- https://github.com/nostr-protocol/nips/blob/master/14.md +- https://github.com/nostr-protocol/nips/blob/master/15.md +- https://github.com/nostr-protocol/nips/blob/master/16.md +- https://github.com/nostr-protocol/nips/blob/master/17.md +- https://github.com/nostr-protocol/nips/blob/master/18.md +- https://github.com/nostr-protocol/nips/blob/master/19.md +- https://github.com/nostr-protocol/nips/blob/master/20.md +- https://github.com/nostr-protocol/nips/blob/master/21.md +- https://github.com/nostr-protocol/nips/blob/master/22.md +- https://github.com/nostr-protocol/nips/blob/master/23.md +- https://github.com/nostr-protocol/nips/blob/master/24.md +- https://github.com/nostr-protocol/nips/blob/master/25.md +- https://github.com/nostr-protocol/nips/blob/master/26.md +- https://github.com/nostr-protocol/nips/blob/master/27.md +- https://github.com/nostr-protocol/nips/blob/master/28.md +- https://github.com/nostr-protocol/nips/blob/master/29.md +- https://github.com/nostr-protocol/nips/blob/master/30.md +- https://github.com/nostr-protocol/nips/blob/master/31.md +- https://github.com/nostr-protocol/nips/blob/master/32.md +- https://github.com/nostr-protocol/nips/blob/master/33.md +- https://github.com/nostr-protocol/nips/blob/master/34.md +- https://github.com/nostr-protocol/nips/blob/master/35.md +- https://github.com/nostr-protocol/nips/blob/master/36.md +- https://github.com/nostr-protocol/nips/blob/master/37.md +- https://github.com/nostr-protocol/nips/blob/master/38.md +- https://github.com/nostr-protocol/nips/blob/master/39.md +- https://github.com/nostr-protocol/nips/blob/master/40.md +- https://github.com/nostr-protocol/nips/blob/master/42.md +- https://github.com/nostr-protocol/nips/blob/master/44.md +- https://github.com/nostr-protocol/nips/blob/master/45.md +- https://github.com/nostr-protocol/nips/blob/master/46.md +- https://github.com/nostr-protocol/nips/blob/master/47.md +- https://github.com/nostr-protocol/nips/blob/master/48.md +- https://github.com/nostr-protocol/nips/blob/master/49.md +- https://github.com/nostr-protocol/nips/blob/master/50.md +- https://github.com/nostr-protocol/nips/blob/master/51.md +- https://github.com/nostr-protocol/nips/blob/master/52.md +- https://github.com/nostr-protocol/nips/blob/master/53.md +- https://github.com/nostr-protocol/nips/blob/master/54.md +- https://github.com/nostr-protocol/nips/blob/master/55.md +- https://github.com/nostr-protocol/nips/blob/master/56.md +- https://github.com/nostr-protocol/nips/blob/master/57.md +- https://github.com/nostr-protocol/nips/blob/master/58.md +- https://github.com/nostr-protocol/nips/blob/master/59.md +- https://github.com/nostr-protocol/nips/blob/master/60.md +- https://github.com/nostr-protocol/nips/blob/master/61.md +- https://github.com/nostr-protocol/nips/blob/master/62.md +- https://github.com/nostr-protocol/nips/blob/master/64.md +- https://github.com/nostr-protocol/nips/blob/master/65.md +- https://github.com/nostr-protocol/nips/blob/master/66.md +- https://github.com/nostr-protocol/nips/blob/master/68.md +- https://github.com/nostr-protocol/nips/blob/master/69.md +- https://github.com/nostr-protocol/nips/blob/master/70.md +- https://github.com/nostr-protocol/nips/blob/master/71.md +- https://github.com/nostr-protocol/nips/blob/master/72.md +- https://github.com/nostr-protocol/nips/blob/master/73.md +- https://github.com/nostr-protocol/nips/blob/master/75.md +- https://github.com/nostr-protocol/nips/blob/master/77.md +- https://github.com/nostr-protocol/nips/blob/master/78.md +- https://github.com/nostr-protocol/nips/blob/master/7D.md +- https://github.com/nostr-protocol/nips/blob/master/84.md +- https://github.com/nostr-protocol/nips/blob/master/86.md +- https://github.com/nostr-protocol/nips/blob/master/87.md +- https://github.com/nostr-protocol/nips/blob/master/88.md +- https://github.com/nostr-protocol/nips/blob/master/89.md +- https://github.com/nostr-protocol/nips/blob/master/90.md +- https://github.com/nostr-protocol/nips/blob/master/92.md +- https://github.com/nostr-protocol/nips/blob/master/94.md +- https://github.com/nostr-protocol/nips/blob/master/96.md +- https://github.com/nostr-protocol/nips/blob/master/98.md +- https://github.com/nostr-protocol/nips/blob/master/99.md +- https://github.com/nostr-protocol/nips/blob/master/A0.md +- https://github.com/nostr-protocol/nips/blob/master/B0.md +- https://github.com/nostr-protocol/nips/blob/master/B7.md +- https://github.com/nostr-protocol/nips/blob/master/C0.md +- https://github.com/nostr-protocol/nips/blob/master/C7.md + +## Description +nostr-java is a java implementation of the nostr protocol. The specification is available on github, here: https://github.com/nostr-protocol/nips +The URL format for the NIPs is https://github.com/nostr-protocol/nips/blob/master/XX.md where XX is the NIP number. For example, the specification for NIP-01 is available at the URL https://github.com/nostr-protocol/nips/blob/master/01.md etc. + + +## Coding +- When writing code, follow the "Clean Code" principles: + - [Clean Code](https://dev.398ja.xyz/books/Clean_Architecture.pdf) + - Relevant chapters: 2, 3, 4, 7, 10, 17 + - [Clean Architecture](https://dev.398ja.xyz/books/Clean_Code.pdf) + - Relevant chapters: All chapters in part III and IV, 7-14. + - [Design Patterns](https://github.com/iluwatar/java-design-patterns) + - Follow design patterns as described in the book, whenever possible. +- When commiting code, follow the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) specification. +- When adding new features, ensure they are compliant with the Cashu specification (NUTs) provided above. +- Make use of the lombok library to reduce boilerplate code. + +## Documentation + +- When generating documentation: + - Follow the Diátaxis framework and classify each document as a tutorial, how-to guide, reference, or explanation. + - Place new Markdown files under `docs/
` matching the chosen category. + - Start each document with a top-level `#` heading and a short introduction that states the purpose. + - Link the document from `docs/README.md` in the corresponding section. + - Use relative links to reference other documents and keep code snippets minimal and tested. + - Consult the following resources on Diátaxis for guidance: + - https://github.blog/developer-skills/documentation-done-right-a-developers-guide/ + - https://diataxis.fr/ + - https://diataxis.fr/start-here/ + - https://diataxis.fr/how-to-use-diataxis/ + - https://diataxis.fr/tutorials/ + - https://diataxis.fr/how-to-guides/ + - https://diataxis.fr/tutorials-how-to/ + - https://diataxis.fr/quality/ + - https://diataxis.fr/complex-hierarchies/ + - https://diataxis.fr/compass/ + +## Testing + +- Always run `mvn -q verify` from the repository root before committing your changes. +- Include the command's output in the PR description. +- If tests fail due to dependency or network issues, mention this in the PR. +- Update the documentation files if you add or modify features. +- Update the `pom.xml` file for new modules or dependencies, ensuring compatibility with Java 21. +- Verify new Dockerfiles or `docker-compose.yml` files by running `docker-compose build`. +- Document new REST endpoints in the API documentation and ensure they are tested. +- Add unit tests for new functionality, covering edge cases. Follow "Clean Code" principles on unit tests, as described in the "Clean Code" book (Chapter 9). +- Ensure modifications to existing code do not break functionality and pass all tests. +- Add integration tests for new features to verify end-to-end functionality. +- Ensure new dependencies or configurations do not introduce security vulnerabilities. +- Add a comment on top of every test method to describe the test in plain English. + +## Pull Requests + +- Always follow the repository's PR submission guidelines and use the PR template located at `.github/pull_request_template.md`. +- Summarize the changes made and describe how they were tested. +- Include any limitations or known issues in the description. +- Ensure all new features are compliant with the Nostr specification (NIPs) provided above. + +## Versioning + +- Follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html) for all releases. +- Update the version in the parent `pom.xml` and all module POMs when preparing a release. +- Use conventional commit types to signal version bumps (fix → patch, feat → minor, BREAKING CHANGE → major). + +## Changelog Maintenance + +- **Always update `CHANGELOG.md`** after any version change or significant code modification. +- Follow the [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) format: + - Group changes under: `Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, `Security` + - List versions in reverse chronological order (newest first) + - Use `[Unreleased]` section for changes not yet in a release + - Include the release date in ISO format: `## [1.0.0] - 2025-12-17` +- Each entry should be a concise, human-readable description of the change +- Reference related issues or PRs where applicable +- Update the changelog in the same commit as the version bump when possible diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..e32d5285 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,174 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +`nostr-java` is a Java SDK for the Nostr protocol. It provides utilities for creating, signing, and publishing Nostr events to relays. The project implements 20+ Nostr Implementation Possibilities (NIPs). + +- **Language**: Java 21+ +- **Build Tool**: Maven +- **Architecture**: Multi-module Maven project with 9 modules + +## Module Architecture + +The codebase follows a layered dependency structure. Understanding this hierarchy is essential for making changes: + +1. **nostr-java-util** – Foundation utilities (no dependencies on other modules) +2. **nostr-java-crypto** – BIP340 Schnorr signatures (depends on util) +3. **nostr-java-base** – Common model classes (depends on crypto, util) +4. **nostr-java-event** – Event and tag definitions (depends on base, crypto, util) +5. **nostr-java-id** – Identity and key handling (depends on base, crypto) +6. **nostr-java-encryption** – Message encryption (depends on base, crypto, id) +7. **nostr-java-client** – WebSocket relay client (depends on event, base) +8. **nostr-java-api** – High-level API (depends on all above) +9. **nostr-java-examples** – Sample applications (depends on api) + +**Key principle**: Lower-level modules cannot depend on higher-level ones. When adding features, place code at the lowest appropriate level. + +## Common Development Commands + +### Building and Testing + +```bash +# Run all unit tests (no Docker required) +mvn clean test + +# Run integration tests (requires Docker for Testcontainers) +mvn clean verify + +# Run integration tests with verbose output +mvn -q verify + +# Install artifacts without tests +mvn install -Dmaven.test.skip=true + +# Run a specific test class +mvn -q test -Dtest=GenericEventBuilderTest + +# Run a specific test method +mvn -q test -Dtest=GenericEventBuilderTest#testSpecificMethod +``` + +### Code Quality + +```bash +# Verify code quality and run all checks +mvn -q verify + +# Generate code coverage report (Jacoco) +mvn verify +# Reports: target/site/jacoco/index.html in each module +``` + +## Key Architectural Patterns + +### Event System + +- **GenericEvent** (`nostr-java-event/src/main/java/nostr/event/impl/GenericEvent.java`) is the core event class +- Events can be built using: + - Direct constructors with `PublicKey` and `Kind`/`Integer` + - Static `GenericEvent.builder()` for flexible construction +- All events must be signed before sending to relays +- Events support both NIP-defined kinds (via `Kind` enum) and custom kinds (via `Integer`) + +### Client Architecture + +Two WebSocket client implementations: + +1. **StandardWebSocketClient** – Blocking, waits for relay responses with configurable timeout +2. **NostrSpringWebSocketClient** – Non-blocking with Spring WebSocket and retry support (3 retries, exponential backoff from 500ms) + +Configuration properties: +- `nostr.websocket.await-timeout-ms=60000` +- `nostr.websocket.poll-interval-ms=500` + +### Tag System + +- Tags are represented by `BaseTag` and subclasses +- Custom tags can be registered via `TagRegistry` +- Serialization/deserialization handled by Jackson with custom serializers in `nostr.event.json.serializer` + +### Identity and Signing + +- `Identity` class manages key pairs +- Events implement `ISignable` interface +- Signing uses Schnorr signatures (BIP340) +- Public keys use Bech32 encoding (npub prefix) + +## NIPs Implementation + +The codebase implements NIPs through dedicated classes in `nostr-java-api`: +- NIP classes (e.g., `NIP01`, `NIP04`, `NIP25`) provide builder methods and utilities +- Event implementations in `nostr-java-event/src/main/java/nostr/event/impl/` +- Refer to `.github/copilot-instructions.md` for the full NIP specification links + +When implementing new NIP support: +1. Add event class in `nostr-java-event` if needed +2. Create NIP helper class in `nostr-java-api` +3. Add tests in both modules +4. Update README.md with NIP reference +5. Add example in `nostr-java-examples` + +## Testing Strategy + +- **Unit tests** (`*Test.java`): No external dependencies, use mocks +- **Integration tests** (`*IT.java`): Use Testcontainers to start `nostr-rs-relay` +- Relay container image can be overridden in `src/test/resources/relay-container.properties` +- Integration tests may be retried once on failure (configured in failsafe plugin) + +## Code Standards + +- **Commit messages**: Must follow conventional commits format: `type(scope): description` + - Allowed types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert` + - See `commit_instructions.md` for full guidelines +- **PR target**: All PRs should target the `develop` branch +- **Code formatting**: Google Java Format (enforced by CI) +- **Test coverage**: Jacoco generates reports (enforced by CI) +- **Required**: All changes must include unit tests and documentation updates + +## Dependency Management + +- **BOM**: `nostr-java-bom` (version 1.1.1) manages all dependency versions +- Root `pom.xml` includes temporary module version overrides until next BOM release +- Never add version numbers to dependencies in child modules – let the BOM manage versions + +## Documentation + +Comprehensive documentation in `docs/`: +- `docs/GETTING_STARTED.md` – Installation and setup +- `docs/howto/use-nostr-java-api.md` – API usage guide +- `docs/howto/streaming-subscriptions.md` – Subscription management +- `docs/howto/custom-events.md` – Creating custom event types +- `docs/reference/nostr-java-api.md` – API reference +- `docs/CODEBASE_OVERVIEW.md` – Module layout and build instructions + +## Common Patterns and Gotchas + +### Event Building +```java +// Using builder for custom kinds +GenericEvent event = GenericEvent.builder() + .kind(customKindInteger) + .content("content") + .pubKey(publicKey) + .build(); + +// Using constructor for standard kinds +GenericEvent event = new GenericEvent(pubKey, Kind.TEXT_NOTE); +``` + +### Signing and Sending +```java +// Sign and send pattern +EventNostr nostr = new NIP01(identity); +nostr.createTextNote("Hello Nostr!") + .sign() + .send(relays); +``` + +### Custom Tags +Register custom tags in `TagRegistry` before deserializing events that contain them. + +### WebSocket Sessions +Spring WebSocket client maintains persistent connections. Always close subscriptions properly to avoid resource leaks. diff --git a/nostr-java-api/pom.xml b/nostr-java-api/pom.xml index 2161d106..596120bd 100644 --- a/nostr-java-api/pom.xml +++ b/nostr-java-api/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 1.1.1 + 1.2.0 ../pom.xml diff --git a/nostr-java-api/src/test/resources/strfry.conf b/nostr-java-api/src/test/resources/strfry.conf new file mode 100644 index 00000000..8657ddbd --- /dev/null +++ b/nostr-java-api/src/test/resources/strfry.conf @@ -0,0 +1,75 @@ +## +## strfry config for integration testing (no whitelist) +## + +db = "./strfry-db/" + +dbParams { + maxreaders = 256 + mapsize = 10995116277760 + noReadAhead = false +} + +events { + maxEventSize = 65536 + rejectEventsNewerThanSeconds = 900 + rejectEventsOlderThanSeconds = 94608000 + rejectEphemeralEventsOlderThanSeconds = 60 + ephemeralEventsLifetimeSeconds = 300 + maxNumTags = 2000 + maxTagValSize = 1024 +} + +relay { + bind = "0.0.0.0" + port = 7777 + nofiles = 1000000 + realIpHeader = "" + + info { + name = "nostr-java test relay" + description = "strfry relay for nostr-java integration tests" + pubkey = "" + contact = "" + icon = "" + nips = "" + } + + maxWebsocketPayloadSize = 131072 + maxReqFilterSize = 200 + autoPingSeconds = 55 + enableTcpKeepalive = false + queryTimesliceBudgetMicroseconds = 10000 + maxFilterLimit = 500 + maxSubsPerConnection = 20 + + writePolicy { + # No write policy plugin - accept all events + plugin = "" + } + + compression { + enabled = true + slidingWindow = true + } + + logging { + dumpInAll = false + dumpInEvents = false + dumpInReqs = false + dbScanPerf = false + invalidEvents = true + } + + numThreads { + ingester = 3 + reqWorker = 3 + reqMonitor = 3 + negentropy = 2 + } + + negentropy { + enabled = true + maxSyncEvents = 1000000 + } +} diff --git a/nostr-java-base/pom.xml b/nostr-java-base/pom.xml index 8b718738..c18f9108 100644 --- a/nostr-java-base/pom.xml +++ b/nostr-java-base/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 1.1.1 + 1.2.0 ../pom.xml diff --git a/nostr-java-client/pom.xml b/nostr-java-client/pom.xml index b2da8b3b..ed9ffab9 100644 --- a/nostr-java-client/pom.xml +++ b/nostr-java-client/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 1.1.1 + 1.2.0 ../pom.xml diff --git a/nostr-java-crypto/pom.xml b/nostr-java-crypto/pom.xml index b07dd3b6..265a82ff 100644 --- a/nostr-java-crypto/pom.xml +++ b/nostr-java-crypto/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 1.1.1 + 1.2.0 ../pom.xml diff --git a/nostr-java-encryption/pom.xml b/nostr-java-encryption/pom.xml index 099cd082..4e29e4bb 100644 --- a/nostr-java-encryption/pom.xml +++ b/nostr-java-encryption/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 1.1.1 + 1.2.0 ../pom.xml diff --git a/nostr-java-event/pom.xml b/nostr-java-event/pom.xml index 6c2d1bc9..c92716f2 100644 --- a/nostr-java-event/pom.xml +++ b/nostr-java-event/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 1.1.1 + 1.2.0 ../pom.xml diff --git a/nostr-java-examples/pom.xml b/nostr-java-examples/pom.xml index 77fc8235..169c1e29 100644 --- a/nostr-java-examples/pom.xml +++ b/nostr-java-examples/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 1.1.1 + 1.2.0 ../pom.xml diff --git a/nostr-java-id/pom.xml b/nostr-java-id/pom.xml index 05b12336..e7df4096 100644 --- a/nostr-java-id/pom.xml +++ b/nostr-java-id/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 1.1.1 + 1.2.0 ../pom.xml diff --git a/nostr-java-util/pom.xml b/nostr-java-util/pom.xml index 612b7825..b7655575 100644 --- a/nostr-java-util/pom.xml +++ b/nostr-java-util/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 1.1.1 + 1.2.0 ../pom.xml diff --git a/pom.xml b/pom.xml index 1ae2d671..b8148f10 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ xyz.tcheeric nostr-java - 1.1.1 + 1.2.0 pom nostr-java From 5a3c9038a3049ef0e8351d518e5a043fb26c6e0b Mon Sep 17 00:00:00 2001 From: tcheeric Date: Fri, 26 Dec 2025 11:27:26 +0000 Subject: [PATCH 06/21] fix(nip44): use proper HKDF-Extract for conversation key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HKDF-Extract is defined as HMAC-Hash(salt, IKM), not the full HKDF Extract+Expand. The previous fix incorrectly used HKDFBytesGenerator.generateBytes() which performs both steps. Now uses Mac with HmacSHA256 directly for Extract, which matches the nostr-tools implementation and NIP-44 specification. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- CHANGELOG.md | 11 +++++++++++ .../nostr/crypto/nip44/EncryptedPayloads.java | 18 +++++++++++------- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2563397..c05b36fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,17 @@ The format is inspired by Keep a Changelog, and this project adheres to semantic No unreleased changes yet. +## [1.2.0] - 2025-12-26 + +### Fixed +- NIP-44 encryption now correctly uses HKDF instead of PBKDF2 for key derivation, as required by the specification. This fix enables DM interoperability between Java backend and JavaScript frontend implementations (e.g., nostr-tools). + +### Changed +- Switched integration tests to use strfry relay for improved robustness. + +### Removed +- Removed AGENTS.md and CLAUDE.md documentation files from the repository. + ## [1.1.1] - 2025-12-24 ### Fixed diff --git a/nostr-java-crypto/src/main/java/nostr/crypto/nip44/EncryptedPayloads.java b/nostr-java-crypto/src/main/java/nostr/crypto/nip44/EncryptedPayloads.java index 399ccb1e..833391ec 100644 --- a/nostr-java-crypto/src/main/java/nostr/crypto/nip44/EncryptedPayloads.java +++ b/nostr-java-crypto/src/main/java/nostr/crypto/nip44/EncryptedPayloads.java @@ -21,6 +21,8 @@ import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; +import java.security.InvalidKeyException; +import java.security.NoSuchAlgorithmException; import java.util.Arrays; import java.util.Base64; @@ -123,13 +125,15 @@ public static byte[] getConversationKey(String privkeyA, String pubkeyB) { byte[] sharedX = pointQ.normalize().getAffineXCoord().getEncoded(); // NIP-44: conversation_key = HKDF-Extract(salt="nip44-v2", ikm=shared_x) - byte[] salt = "nip44-v2".getBytes(StandardCharsets.UTF_8); - HKDFBytesGenerator hkdf = new HKDFBytesGenerator(new SHA256Digest()); - hkdf.init(new HKDFParameters(sharedX, salt, null)); - byte[] conversationKey = new byte[32]; - hkdf.generateBytes(conversationKey, 0, 32); - - return conversationKey; + // HKDF-Extract is defined as: PRK = HMAC-Hash(salt, IKM) + try { + byte[] salt = "nip44-v2".getBytes(StandardCharsets.UTF_8); + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(salt, "HmacSHA256")); + return mac.doFinal(sharedX); + } catch (NoSuchAlgorithmException | InvalidKeyException e) { + throw new RuntimeException("HKDF-Extract failed", e); + } } public static byte[] hexStringToByteArray(String s) { From f1227456ebf36eda84fde8745cdfff13601f92f4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 12 Jan 2026 18:15:14 +0000 Subject: [PATCH 07/21] chore(deps): bump org.sonatype.central:central-publishing-maven-plugin Bumps [org.sonatype.central:central-publishing-maven-plugin](https://github.com/sonatype/central-publishing-maven-plugin) from 0.9.0 to 0.10.0. - [Commits](https://github.com/sonatype/central-publishing-maven-plugin/commits) --- updated-dependencies: - dependency-name: org.sonatype.central:central-publishing-maven-plugin dependency-version: 0.10.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 1ae2d671..ffd088c8 100644 --- a/pom.xml +++ b/pom.xml @@ -84,7 +84,7 @@ 1.1.8 - 0.9.0 + 0.10.0 3.3.1 3.11.3 3.2.8 From de36c4d1ceacc21f58126c72463b82d997433f24 Mon Sep 17 00:00:00 2001 From: tcheeric Date: Wed, 21 Jan 2026 02:29:48 +0000 Subject: [PATCH 08/21] docs: enhance coding guidelines with detailed principles and best practices --- AGENTS.md | 110 +++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 100 insertions(+), 10 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3f4d9d33..945efe36 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,16 +98,106 @@ The URL format for the NIPs is https://github.com/nostr-protocol/nips/blob/maste ## Coding -- When writing code, follow the "Clean Code" principles: - - [Clean Code](https://dev.398ja.xyz/books/Clean_Architecture.pdf) - - Relevant chapters: 2, 3, 4, 7, 10, 17 - - [Clean Architecture](https://dev.398ja.xyz/books/Clean_Code.pdf) - - Relevant chapters: All chapters in part III and IV, 7-14. - - [Design Patterns](https://github.com/iluwatar/java-design-patterns) - - Follow design patterns as described in the book, whenever possible. -- When commiting code, follow the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) specification. -- When adding new features, ensure they are compliant with the Cashu specification (NUTs) provided above. -- Make use of the lombok library to reduce boilerplate code. + +When writing code, follow Clean Code and Clean Architecture principles: + +### Meaningful Names +- Use intention-revealing names that explain why something exists, what it does, and how it's used +- Avoid disinformation: don't use names that could mislead (e.g., `accountList` for something that isn't a List) +- Make meaningful distinctions: avoid noise words like `Info`, `Data`, `Manager` unless they add real meaning +- Use pronounceable and searchable names; avoid single-letter variables except for loop counters +- Class names should be nouns (`Customer`, `Account`); method names should be verbs (`postPayment`, `save`) +- Pick one word per concept and stick with it (`fetch`, `retrieve`, `get` — pick one) + +### Functions +- Keep functions small: ideally under 20 lines, rarely exceeding one screen +- Functions should do one thing, do it well, and do it only +- One level of abstraction per function: don't mix high-level policy with low-level details +- Use descriptive names: a long descriptive name is better than a short cryptic one +- Minimize arguments: zero is ideal, one or two are fine, three requires justification +- Avoid flag arguments (boolean parameters that change behavior) +- Functions should either do something or answer something, never both (Command-Query Separation) +- Prefer exceptions over error codes; extract try/catch blocks into their own functions + +### Comments +- Comments are a failure to express intent in code; prefer self-documenting code +- Good comments: legal comments, explanation of intent, clarification, warning of consequences, TODO notes, Javadoc for public APIs +- Bad comments: redundant comments, misleading comments, mandated comments, journal comments, noise comments, commented-out code +- If you must comment, explain *why*, not *what* — the code shows what + +### Error Handling +- Use exceptions rather than return codes +- Write try-catch-finally statements first when writing code that could throw +- Use unchecked exceptions; checked exceptions violate the Open/Closed Principle +- Provide context with exceptions: include operation attempted and failure type +- Define exception classes by how they're caught, not by their source +- Don't return null — throw an exception or return a Special Case object instead +- Don't pass null as arguments unless the API explicitly expects it + +### Classes +- Classes should be small, measured by responsibilities (Single Responsibility Principle) +- A class should have only one reason to change +- High cohesion: methods and variables should be closely related +- Organize for change: isolate code that's likely to change from code that's stable +- Depend on abstractions, not concretions (Dependency Inversion) +- Classes should be open for extension but closed for modification (Open/Closed Principle) + +### Code Smells and Heuristics +- Avoid comments that could be replaced by better naming or structure +- Eliminate dead code, duplicate code, and code at wrong levels of abstraction +- Keep configuration data at high levels; don't bury magic numbers +- Follow the Law of Demeter: modules shouldn't know about the innards of objects they manipulate +- Make logical dependencies physical: if one module depends on another, make that explicit +- Prefer polymorphism to if/else or switch/case chains +- Follow standard conventions for the project and language +- Replace magic numbers with named constants +- Be precise: don't be lazy about decisions — if you decide to use a list, be sure you need one +- Encapsulate conditionals: extract complex boolean expressions into well-named methods +- Avoid negative conditionals: `if (buffer.shouldCompact())` is clearer than `if (!buffer.shouldNotCompact())` +- Functions should descend one level of abstraction + +### SOLID Design Principles + +**Single Responsibility Principle (SRP)** +A module should have one, and only one, reason to change. Each class serves one actor or stakeholder. When requirements change for one actor, only the relevant module changes. + +**Open/Closed Principle (OCP)** +Software entities should be open for extension but closed for modification. Achieve this through abstraction and polymorphism — add new behavior by adding new code, not changing existing code. + +**Liskov Substitution Principle (LSP)** +Subtypes must be substitutable for their base types without altering program correctness. If S is a subtype of T, objects of type T may be replaced with objects of type S without breaking the program. + +**Interface Segregation Principle (ISP)** +Clients should not be forced to depend on interfaces they don't use. Prefer many small, client-specific interfaces over one general-purpose interface. + +**Dependency Inversion Principle (DIP)** +High-level modules should not depend on low-level modules; both should depend on abstractions. Abstractions should not depend on details; details should depend on abstractions. + +### Component Principles + +**Cohesion Principles:** +- **REP (Reuse/Release Equivalence)**: The granule of reuse is the granule of release — classes in a component should be releasable together +- **CCP (Common Closure)**: Gather classes that change for the same reasons at the same times; separate classes that change at different times for different reasons +- **CRP (Common Reuse)**: Don't force users to depend on things they don't need — classes in a component should be used together + +**Coupling Principles:** +- **ADP (Acyclic Dependencies)**: No cycles in the component dependency graph; use Dependency Inversion to break cycles +- **SDP (Stable Dependencies)**: Depend in the direction of stability — volatile components should depend on stable ones +- **SAP (Stable Abstractions)**: Stable components should be abstract; instability should be concrete + +### Design Patterns +Apply established patterns where appropriate: +- **Creational**: Factory Method, Abstract Factory, Builder, Singleton (use sparingly), Prototype +- **Structural**: Adapter, Bridge, Composite, Decorator, Facade, Proxy +- **Behavioral**: Strategy, Observer, Command, State, Template Method, Iterator, Chain of Responsibility + +Choose patterns that simplify the design; don't force patterns where simpler solutions suffice. + +### General Guidelines +- When committing code, follow the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) specification +- When adding new features, ensure they are compliant with the Nostr specification (NIPs) provided above +- Make use of the Lombok library to reduce boilerplate code +- Always rely on imports rather than fully qualified class names in code to keep implementations readable ## Documentation From 64c186e3edb2b8e5388b148debd33d323cea65b8 Mon Sep 17 00:00:00 2001 From: tcheeric Date: Wed, 21 Jan 2026 02:29:55 +0000 Subject: [PATCH 09/21] fix(Kind): improve error handling for unknown kind values --- nostr-java-base/src/main/java/nostr/base/Kind.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/nostr-java-base/src/main/java/nostr/base/Kind.java b/nostr-java-base/src/main/java/nostr/base/Kind.java index da768d01..d8701a61 100644 --- a/nostr-java-base/src/main/java/nostr/base/Kind.java +++ b/nostr-java-base/src/main/java/nostr/base/Kind.java @@ -76,7 +76,8 @@ public static Kind valueOf(int value) { } } - return TEXT_NOTE; + throw new IllegalArgumentException( + String.format("Unknown kind value: %d. Add it to the Kind enum if it's a valid NIP kind.", value)); } @Override From e3cb5a0fd03b73bc963709db8e7e6e279b22fb39 Mon Sep 17 00:00:00 2001 From: tcheeric Date: Wed, 21 Jan 2026 02:30:03 +0000 Subject: [PATCH 10/21] fix(ws): refactor response handling to use CompletableFuture for event-driven notifications --- .../StandardWebSocketClient.java | 104 +++++++++++------- 1 file changed, 62 insertions(+), 42 deletions(-) diff --git a/nostr-java-client/src/main/java/nostr/client/springwebsocket/StandardWebSocketClient.java b/nostr-java-client/src/main/java/nostr/client/springwebsocket/StandardWebSocketClient.java index 0fb0a508..c65d860d 100644 --- a/nostr-java-client/src/main/java/nostr/client/springwebsocket/StandardWebSocketClient.java +++ b/nostr-java-client/src/main/java/nostr/client/springwebsocket/StandardWebSocketClient.java @@ -3,7 +3,6 @@ import lombok.NonNull; import lombok.extern.slf4j.Slf4j; import nostr.event.BaseMessage; -import org.awaitility.core.ConditionTimeoutException; import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.context.annotation.Scope; @@ -19,23 +18,31 @@ import java.io.IOException; import java.net.URI; -import java.time.Duration; import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; -import static org.awaitility.Awaitility.await; - +/** + * WebSocket client for Nostr relay communication. + * + *

This client uses {@link CompletableFuture} for response waiting, providing instant + * notification when responses arrive instead of polling. This eliminates race conditions + * that can occur with polling-based approaches where the response may arrive between + * poll intervals. + */ @Component @Scope(BeanDefinition.SCOPE_PROTOTYPE) @Slf4j public class StandardWebSocketClient extends TextWebSocketHandler implements WebSocketClientIF { - private static final Duration DEFAULT_AWAIT_TIMEOUT = Duration.ofSeconds(60); - private static final Duration DEFAULT_POLL_INTERVAL = Duration.ofMillis(500); + private static final long DEFAULT_AWAIT_TIMEOUT_MS = 60000L; /** Default max idle timeout for WebSocket sessions (1 hour). Set to 0 for no timeout. */ private static final long DEFAULT_MAX_IDLE_TIMEOUT_MS = 3600000L; @@ -43,16 +50,15 @@ public class StandardWebSocketClient extends TextWebSocketHandler implements Web private long awaitTimeoutMs; @Value("${nostr.websocket.poll-interval-ms:500}") - private long pollIntervalMs; + private long pollIntervalMs; // Kept for API compatibility, no longer used for polling @Value("${nostr.websocket.max-idle-timeout-ms:3600000}") private long maxIdleTimeoutMs; private final WebSocketSession clientSession; - private final AtomicBoolean completed = new AtomicBoolean(false); private final Object sendLock = new Object(); private List events = new ArrayList<>(); - private volatile boolean awaitingResponse = false; + private CompletableFuture> responseFuture; private final Map listeners = new ConcurrentHashMap<>(); private final AtomicBoolean connectionClosed = new AtomicBoolean(false); @@ -80,7 +86,7 @@ public StandardWebSocketClient(@Value("${nostr.relay.uri}") String relayUri) * * @param relayUri the URI of the relay to connect to * @param awaitTimeoutMs timeout in milliseconds for awaiting relay responses (must be positive) - * @param pollIntervalMs polling interval in milliseconds for checking responses (must be positive) + * @param pollIntervalMs polling interval in milliseconds (kept for API compatibility, no longer used) * @throws java.util.concurrent.ExecutionException if the WebSocket session fails to establish * @throws InterruptedException if the current thread is interrupted while waiting for the * WebSocket handshake to complete @@ -96,7 +102,7 @@ public StandardWebSocketClient(String relayUri, long awaitTimeoutMs, long pollIn } this.awaitTimeoutMs = awaitTimeoutMs; this.pollIntervalMs = pollIntervalMs; - log.info("StandardWebSocketClient created for {} with awaitTimeoutMs={}, pollIntervalMs={}", + log.info("StandardWebSocketClient created for {} with awaitTimeoutMs={}, pollIntervalMs={} (event-driven, no polling)", relayUri, awaitTimeoutMs, pollIntervalMs); this.clientSession = createSpringClient() .execute(this, new WebSocketHttpHeaders(), URI.create(relayUri)) @@ -124,9 +130,11 @@ protected void handleTextMessage(@NonNull WebSocketSession session, TextMessage log.debug("Relay payload received: {}", message.getPayload()); dispatchMessage(message.getPayload()); synchronized (sendLock) { - if (awaitingResponse) { + if (responseFuture != null && !responseFuture.isDone()) { events.add(message.getPayload()); - completed.setRelease(true); + // Complete the future with the current events - instant notification + responseFuture.complete(List.copyOf(events)); + log.debug("Response future completed with {} events", events.size()); } } } @@ -136,8 +144,9 @@ public void handleTransportError(@NonNull WebSocketSession session, @NonNull Thr log.warn("Transport error on WebSocket session", exception); notifyError(exception); synchronized (sendLock) { - awaitingResponse = false; - completed.setRelease(true); + if (responseFuture != null && !responseFuture.isDone()) { + responseFuture.completeExceptionally(exception); + } } } @@ -149,8 +158,10 @@ public void afterConnectionClosed(@NonNull WebSocketSession session, @NonNull Cl notifyClose(); } synchronized (sendLock) { - awaitingResponse = false; - completed.setRelease(true); + if (responseFuture != null && !responseFuture.isDone()) { + responseFuture.completeExceptionally( + new IOException("WebSocket connection closed: " + status)); + } } } @@ -161,43 +172,52 @@ public List send(T eventMessage) throws IOExcept @Override public List send(String json) throws IOException { + CompletableFuture> future; + synchronized (sendLock) { events = new ArrayList<>(); - awaitingResponse = true; - completed.setRelease(false); - log.info("Sending subscription request to relay {}: {}", clientSession.getUri(), json); + responseFuture = new CompletableFuture<>(); + future = responseFuture; + log.info("Sending request to relay {}: {}", clientSession.getUri(), json); clientSession.sendMessage(new TextMessage(json)); } - Duration awaitTimeout = - awaitTimeoutMs > 0 ? Duration.ofMillis(awaitTimeoutMs) : DEFAULT_AWAIT_TIMEOUT; - Duration pollInterval = - pollIntervalMs > 0 ? Duration.ofMillis(pollIntervalMs) : DEFAULT_POLL_INTERVAL; - log.debug("Waiting for relay response with timeout={}ms, poll={}ms", - awaitTimeout.toMillis(), pollInterval.toMillis()); + + long timeout = awaitTimeoutMs > 0 ? awaitTimeoutMs : DEFAULT_AWAIT_TIMEOUT_MS; + log.debug("Waiting for relay response with timeout={}ms (event-driven)", timeout); + try { - await().atMost(awaitTimeout).pollInterval(pollInterval).untilTrue(completed); - } catch (ConditionTimeoutException e) { - log.error("Timed out waiting for relay response after {}ms (configured: awaitTimeoutMs={}, pollIntervalMs={})", - awaitTimeout.toMillis(), this.awaitTimeoutMs, this.pollIntervalMs, e); + List result = future.get(timeout, TimeUnit.MILLISECONDS); + log.info("Received {} relay events via {}", result.size(), clientSession.getUri()); + return result; + } catch (TimeoutException e) { + log.error("Timed out waiting for relay response after {}ms", timeout); + synchronized (sendLock) { + if (responseFuture == future) { + responseFuture = null; + } + events = new ArrayList<>(); + } try { clientSession.close(); } catch (IOException closeEx) { log.warn("Error closing session after timeout", closeEx); } + return List.of(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while waiting for relay response", e); + } catch (ExecutionException e) { + Throwable cause = e.getCause(); + if (cause instanceof IOException) { + throw (IOException) cause; + } + throw new IOException("Error waiting for relay response", cause); + } finally { synchronized (sendLock) { - events = new ArrayList<>(); - awaitingResponse = false; - completed.setRelease(false); + if (responseFuture == future) { + responseFuture = null; + } } - return List.of(); - } - synchronized (sendLock) { - List eventList = List.copyOf(events); - log.info("Received {} relay events via {}", eventList.size(), clientSession.getUri()); - events = new ArrayList<>(); - awaitingResponse = false; - completed.setRelease(false); - return eventList; } } From 2a53b78a26d2ebea597aa6b552e5214069db395d Mon Sep 17 00:00:00 2001 From: tcheeric Date: Wed, 21 Jan 2026 02:36:21 +0000 Subject: [PATCH 11/21] chore(release): bump version to 1.2.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Projects updated: - nostr-java: 1.2.0 → 1.2.1 (patch) - All modules: 1.2.0 → 1.2.1 (patch) Fixes included: - NIP-44 HKDF-Extract for conversation key derivation - Kind enum error handling for unknown values - WebSocket CompletableFuture response handling Co-Authored-By: Claude Opus 4.5 --- CHANGELOG.md | 7 +++ nostr-java-api/pom.xml | 2 +- nostr-java-base/pom.xml | 2 +- nostr-java-client/pom.xml | 2 +- nostr-java-crypto/pom.xml | 2 +- nostr-java-encryption/pom.xml | 2 +- nostr-java-event/pom.xml | 2 +- .../nostr/event/unit/Nip60FilterJsonTest.java | 60 +++++++++++++++++++ nostr-java-examples/pom.xml | 2 +- nostr-java-id/pom.xml | 2 +- nostr-java-util/pom.xml | 2 +- pom.xml | 2 +- 12 files changed, 77 insertions(+), 10 deletions(-) create mode 100644 nostr-java-event/src/test/java/nostr/event/unit/Nip60FilterJsonTest.java diff --git a/CHANGELOG.md b/CHANGELOG.md index c05b36fb..29adfa2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ The format is inspired by Keep a Changelog, and this project adheres to semantic No unreleased changes yet. +## [1.2.1] - 2026-01-21 + +### Fixed +- NIP-44 now correctly uses HKDF-Extract for conversation key derivation, ensuring proper cryptographic key generation. +- Kind enum error handling improved for unknown kind values to prevent runtime exceptions. +- WebSocket client response handling refactored to use CompletableFuture for event-driven notifications, improving reliability and responsiveness. + ## [1.2.0] - 2025-12-26 ### Fixed diff --git a/nostr-java-api/pom.xml b/nostr-java-api/pom.xml index 596120bd..12d640e3 100644 --- a/nostr-java-api/pom.xml +++ b/nostr-java-api/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 1.2.0 + 1.2.1 ../pom.xml diff --git a/nostr-java-base/pom.xml b/nostr-java-base/pom.xml index c18f9108..3749b8c6 100644 --- a/nostr-java-base/pom.xml +++ b/nostr-java-base/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 1.2.0 + 1.2.1 ../pom.xml diff --git a/nostr-java-client/pom.xml b/nostr-java-client/pom.xml index ed9ffab9..be230114 100644 --- a/nostr-java-client/pom.xml +++ b/nostr-java-client/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 1.2.0 + 1.2.1 ../pom.xml diff --git a/nostr-java-crypto/pom.xml b/nostr-java-crypto/pom.xml index 265a82ff..43b73675 100644 --- a/nostr-java-crypto/pom.xml +++ b/nostr-java-crypto/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 1.2.0 + 1.2.1 ../pom.xml diff --git a/nostr-java-encryption/pom.xml b/nostr-java-encryption/pom.xml index 4e29e4bb..d7c8c9f7 100644 --- a/nostr-java-encryption/pom.xml +++ b/nostr-java-encryption/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 1.2.0 + 1.2.1 ../pom.xml diff --git a/nostr-java-event/pom.xml b/nostr-java-event/pom.xml index c92716f2..11f1435a 100644 --- a/nostr-java-event/pom.xml +++ b/nostr-java-event/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 1.2.0 + 1.2.1 ../pom.xml diff --git a/nostr-java-event/src/test/java/nostr/event/unit/Nip60FilterJsonTest.java b/nostr-java-event/src/test/java/nostr/event/unit/Nip60FilterJsonTest.java new file mode 100644 index 00000000..273ec43f --- /dev/null +++ b/nostr-java-event/src/test/java/nostr/event/unit/Nip60FilterJsonTest.java @@ -0,0 +1,60 @@ +package nostr.event.unit; + +import nostr.base.GenericTagQuery; +import nostr.base.Kind; +import nostr.base.PublicKey; +import nostr.event.filter.AuthorFilter; +import nostr.event.filter.Filters; +import nostr.event.filter.GenericTagQueryFilter; +import nostr.event.filter.KindFilter; +import nostr.event.json.codec.FiltersEncoder; +import nostr.event.message.ReqMessage; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * Test to verify NIP-60 filter JSON serialization format. + */ +public class Nip60FilterJsonTest { + + @Test + public void testNip60FilterJsonFormat() { + String pubkey = "f1b419a95cb0233a11d431423b41a42734e7165fcab16081cd08ef1c90e0be75"; + + // First filter: kinds + authors (like Nip60SyncService first group) + Filters filter1 = new Filters(List.of( + new KindFilter<>(Kind.valueOf(17375)), + new KindFilter<>(Kind.valueOf(7375)), + new KindFilter<>(Kind.valueOf(7376)), + new AuthorFilter<>(new PublicKey(pubkey)) + )); + + // Second filter: kind + #a tag (like Nip60SyncService second group) + Filters filter2 = new Filters(List.of( + new KindFilter<>(Kind.valueOf(7375)), + new GenericTagQueryFilter<>(new GenericTagQuery("#a", "17375:" + pubkey)) + )); + + ReqMessage req = new ReqMessage("test-sub-id", List.of(filter1, filter2)); + + String reqJson = req.encode(); + System.out.println("REQ Message JSON:"); + System.out.println(reqJson); + + // Also test single filter encoding + String filter1Json = new FiltersEncoder(filter1).encode(); + System.out.println("\nFilter 1 encoded separately:"); + System.out.println(filter1Json); + + String filter2Json = new FiltersEncoder(filter2).encode(); + System.out.println("\nFilter 2 encoded separately:"); + System.out.println(filter2Json); + + assertNotNull(reqJson); + assertNotNull(filter1Json); + assertNotNull(filter2Json); + } +} diff --git a/nostr-java-examples/pom.xml b/nostr-java-examples/pom.xml index 169c1e29..571c8657 100644 --- a/nostr-java-examples/pom.xml +++ b/nostr-java-examples/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 1.2.0 + 1.2.1 ../pom.xml diff --git a/nostr-java-id/pom.xml b/nostr-java-id/pom.xml index e7df4096..100969d7 100644 --- a/nostr-java-id/pom.xml +++ b/nostr-java-id/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 1.2.0 + 1.2.1 ../pom.xml diff --git a/nostr-java-util/pom.xml b/nostr-java-util/pom.xml index b7655575..4efd630a 100644 --- a/nostr-java-util/pom.xml +++ b/nostr-java-util/pom.xml @@ -4,7 +4,7 @@ xyz.tcheeric nostr-java - 1.2.0 + 1.2.1 ../pom.xml diff --git a/pom.xml b/pom.xml index b8148f10..2d8eab21 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ xyz.tcheeric nostr-java - 1.2.0 + 1.2.1 pom nostr-java From 4167c64b19b34634a3b17e0b17560bdb34641f21 Mon Sep 17 00:00:00 2001 From: tcheeric Date: Wed, 21 Jan 2026 02:42:36 +0000 Subject: [PATCH 12/21] chore(docs): update PR template with standardized sections and guidelines --- .github/pull_request_template.md | 48 ++++++++++++++++++++++++-------- 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 3e99b37a..68e024bd 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,21 +1,45 @@ ## Summary - -Related issue: #____ + + +Closes #____ + +## Type of change + + + +- [ ] `fix` - Bug fix (non-breaking) +- [ ] `feat` - New feature (non-breaking) +- [ ] `refactor` - Code change that neither fixes a bug nor adds a feature +- [ ] `docs` - Documentation only +- [ ] `test` - Adding or updating tests +- [ ] `perf` - Performance improvement +- [ ] `chore` - Build, CI, or tooling changes ## What changed? - -## BREAKING - - + + +## Breaking changes + + + + +## Testing + + + +- [ ] Unit tests pass: `mvn test` +- [ ] Integration tests pass: `mvn verify` (requires Docker) ## Review focus - + + ## Checklist -- [ ] Scope ≤ 300 lines (or split/stack) -- [ ] Title is **verb + object** (e.g., “Refactor auth middleware to async”) -- [ ] Description links the issue and answers “why now?” -- [ ] **BREAKING** flagged if needed -- [ ] Tests/docs updated (if relevant) + +- [ ] PR title follows conventional commits: `type(scope): description` +- [ ] Changes are focused and under 300 lines (or stacked PRs) +- [ ] Tests added/updated for new functionality +- [ ] No new compiler warnings introduced +- [ ] CHANGELOG.md updated (for user-facing changes) From 9a3ace08499d5f0e9eeab1e722a9688ef73a63b4 Mon Sep 17 00:00:00 2001 From: tcheeric Date: Wed, 21 Jan 2026 03:35:25 +0000 Subject: [PATCH 13/21] fix(Kind): add safer Optional-based lookup and strict validation methods Added `findByValue` for Optional-based Kind lookups and `valueOfStrict` for strict validation, improving flexibility and error handling for unknown or custom kinds. --- .../src/main/java/nostr/base/Kind.java | 52 ++++++++++++++++++- 1 file changed, 50 insertions(+), 2 deletions(-) diff --git a/nostr-java-base/src/main/java/nostr/base/Kind.java b/nostr-java-base/src/main/java/nostr/base/Kind.java index d8701a61..67c2ab7b 100644 --- a/nostr-java-base/src/main/java/nostr/base/Kind.java +++ b/nostr-java-base/src/main/java/nostr/base/Kind.java @@ -6,6 +6,7 @@ import lombok.Getter; import java.time.temporal.ValueRange; +import java.util.Optional; /** * @author squirrel @@ -64,6 +65,20 @@ public enum Kind { private final String name; + /** + * Returns the Kind enum constant for the given integer value. + * + *

This method is used by Jackson for JSON deserialization. For unknown kind values + * (valid range but not defined in this enum), it returns {@code null} to allow handling + * of custom or future NIP kinds that aren't yet defined in this library. + * + *

For strict validation that throws on unknown kinds, use {@link #valueOfStrict(int)}. + * For a safer Optional-based lookup, use {@link #findByValue(int)}. + * + * @param value the kind integer value (must be between 0 and 65535) + * @return the Kind enum constant, or {@code null} if the value is valid but not defined + * @throws IllegalArgumentException if the value is outside the valid range (0-65535) + */ @JsonCreator public static Kind valueOf(int value) { if (!ValueRange.of(0, 65_535).isValidIntValue(value)) { @@ -75,9 +90,42 @@ public static Kind valueOf(int value) { return k; } } + return null; + } - throw new IllegalArgumentException( - String.format("Unknown kind value: %d. Add it to the Kind enum if it's a valid NIP kind.", value)); + /** + * Returns the Kind enum constant for the given integer value, throwing if not found. + * + *

Use this method when you require a known Kind and want to fail fast on unknown values. + * For lenient handling of custom kinds, use {@link #valueOf(int)} or {@link #findByValue(int)}. + * + * @param value the kind integer value (must be between 0 and 65535) + * @return the Kind enum constant + * @throws IllegalArgumentException if the value is outside the valid range or unknown + */ + public static Kind valueOfStrict(int value) { + Kind kind = valueOf(value); + if (kind == null) { + throw new IllegalArgumentException( + String.format("Unknown kind value: %d. Use valueOf() for lenient handling or add it to the Kind enum.", value)); + } + return kind; + } + + /** + * Safely looks up a Kind by its integer value, returning an Optional. + * + *

This is the recommended method for handling potentially unknown kinds, as it makes + * the possibility of unknown values explicit in the API. + * + * @param value the kind integer value + * @return an Optional containing the Kind if found, or empty if unknown or out of range + */ + public static Optional findByValue(int value) { + if (!ValueRange.of(0, 65_535).isValidIntValue(value)) { + return Optional.empty(); + } + return Optional.ofNullable(valueOf(value)); } @Override From 737bee174aaf2a82e8d5c5e898ef162a826590c4 Mon Sep 17 00:00:00 2001 From: tcheeric Date: Wed, 21 Jan 2026 03:35:42 +0000 Subject: [PATCH 14/21] test(Kind): expand unit tests for improved validation coverage Added tests for `valueOfStrict` and `findByValue` methods, ensuring strict validation and Optional-based lookups for unknown, valid, and invalid range Kind values. --- .../src/test/java/nostr/base/KindTest.java | 47 ++++++++++++++++++- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/nostr-java-base/src/test/java/nostr/base/KindTest.java b/nostr-java-base/src/test/java/nostr/base/KindTest.java index 9128001d..78d808e9 100644 --- a/nostr-java-base/src/test/java/nostr/base/KindTest.java +++ b/nostr-java-base/src/test/java/nostr/base/KindTest.java @@ -2,9 +2,13 @@ import org.junit.jupiter.api.Test; +import java.util.Optional; + import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; class KindTest { @@ -16,9 +20,9 @@ void testValueOfValid() { } @Test - void testValueOfUnknownReturnsTextNote() { + void testValueOfUnknownReturnsNull() { Kind kind = Kind.valueOf(999); - assertEquals(Kind.TEXT_NOTE, kind); + assertNull(kind, "Unknown kind values should return null for lenient handling"); } @Test @@ -26,6 +30,45 @@ void testValueOfInvalidRange() { assertThrows(IllegalArgumentException.class, () -> Kind.valueOf(70_000)); } + @Test + void testValueOfStrictValid() { + Kind kind = Kind.valueOfStrict(Kind.REACTION.getValue()); + assertEquals(Kind.REACTION, kind); + } + + @Test + void testValueOfStrictUnknownThrows() { + IllegalArgumentException ex = assertThrows( + IllegalArgumentException.class, + () -> Kind.valueOfStrict(999) + ); + assertTrue(ex.getMessage().contains("999")); + } + + @Test + void testValueOfStrictInvalidRangeThrows() { + assertThrows(IllegalArgumentException.class, () -> Kind.valueOfStrict(70_000)); + } + + @Test + void testFindByValueValid() { + Optional kind = Kind.findByValue(Kind.ZAP_RECEIPT.getValue()); + assertTrue(kind.isPresent()); + assertEquals(Kind.ZAP_RECEIPT, kind.get()); + } + + @Test + void testFindByValueUnknownReturnsEmpty() { + Optional kind = Kind.findByValue(999); + assertTrue(kind.isEmpty(), "Unknown kind should return empty Optional"); + } + + @Test + void testFindByValueInvalidRangeReturnsEmpty() { + Optional kind = Kind.findByValue(70_000); + assertTrue(kind.isEmpty(), "Out of range kind should return empty Optional"); + } + @Test void testEnumValues() { for (Kind k : Kind.values()) { From 88049cb94d72ad0986c53bae177bdfd143e19bff Mon Sep 17 00:00:00 2001 From: tcheeric Date: Wed, 21 Jan 2026 03:35:50 +0000 Subject: [PATCH 15/21] test(ws): enhance NIP-60 filter JSON serialization tests for improved coverage --- .../nostr/event/unit/Nip60FilterJsonTest.java | 83 +++++++++++++------ 1 file changed, 56 insertions(+), 27 deletions(-) diff --git a/nostr-java-event/src/test/java/nostr/event/unit/Nip60FilterJsonTest.java b/nostr-java-event/src/test/java/nostr/event/unit/Nip60FilterJsonTest.java index 273ec43f..368e82b1 100644 --- a/nostr-java-event/src/test/java/nostr/event/unit/Nip60FilterJsonTest.java +++ b/nostr-java-event/src/test/java/nostr/event/unit/Nip60FilterJsonTest.java @@ -14,47 +14,76 @@ import java.util.List; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * Test to verify NIP-60 filter JSON serialization format. */ -public class Nip60FilterJsonTest { +class Nip60FilterJsonTest { + + private static final String TEST_PUBKEY = "f1b419a95cb0233a11d431423b41a42734e7165fcab16081cd08ef1c90e0be75"; @Test - public void testNip60FilterJsonFormat() { - String pubkey = "f1b419a95cb0233a11d431423b41a42734e7165fcab16081cd08ef1c90e0be75"; - - // First filter: kinds + authors (like Nip60SyncService first group) - Filters filter1 = new Filters(List.of( - new KindFilter<>(Kind.valueOf(17375)), - new KindFilter<>(Kind.valueOf(7375)), - new KindFilter<>(Kind.valueOf(7376)), - new AuthorFilter<>(new PublicKey(pubkey)) + void testNip60WalletFilterJsonFormat() { + // First filter: wallet-related kinds + author + Filters filter = new Filters(List.of( + new KindFilter<>(Kind.WALLET), + new KindFilter<>(Kind.WALLET_UNSPENT_PROOF), + new KindFilter<>(Kind.WALLET_TX_HISTORY), + new AuthorFilter<>(new PublicKey(TEST_PUBKEY)) )); - // Second filter: kind + #a tag (like Nip60SyncService second group) - Filters filter2 = new Filters(List.of( - new KindFilter<>(Kind.valueOf(7375)), - new GenericTagQueryFilter<>(new GenericTagQuery("#a", "17375:" + pubkey)) + String filterJson = new FiltersEncoder(filter).encode(); + + assertNotNull(filterJson); + // Verify kinds are present with correct NIP-60 values + assertTrue(filterJson.contains("17375"), "Should contain WALLET kind (17375)"); + assertTrue(filterJson.contains("7375"), "Should contain WALLET_UNSPENT_PROOF kind (7375)"); + assertTrue(filterJson.contains("7376"), "Should contain WALLET_TX_HISTORY kind (7376)"); + // Verify author pubkey is present + assertTrue(filterJson.contains(TEST_PUBKEY), "Should contain author pubkey"); + // Verify JSON structure has expected fields + assertTrue(filterJson.contains("\"kinds\""), "Should have 'kinds' field"); + assertTrue(filterJson.contains("\"authors\""), "Should have 'authors' field"); + } + + @Test + void testNip60ProofFilterWithTagQuery() { + // Filter with kind + #a tag query (wallet proof lookup by wallet reference) + String walletRef = Kind.WALLET.getValue() + ":" + TEST_PUBKEY; + Filters filter = new Filters(List.of( + new KindFilter<>(Kind.WALLET_UNSPENT_PROOF), + new GenericTagQueryFilter<>(new GenericTagQuery("#a", walletRef)) )); - ReqMessage req = new ReqMessage("test-sub-id", List.of(filter1, filter2)); + String filterJson = new FiltersEncoder(filter).encode(); - String reqJson = req.encode(); - System.out.println("REQ Message JSON:"); - System.out.println(reqJson); + assertNotNull(filterJson); + assertTrue(filterJson.contains("7375"), "Should contain WALLET_UNSPENT_PROOF kind"); + assertTrue(filterJson.contains("\"#a\""), "Should have '#a' tag filter"); + assertTrue(filterJson.contains(walletRef), "Should contain wallet reference in #a tag"); + } + + @Test + void testNip60ReqMessageFormat() { + Filters walletFilter = new Filters(List.of( + new KindFilter<>(Kind.WALLET), + new AuthorFilter<>(new PublicKey(TEST_PUBKEY)) + )); - // Also test single filter encoding - String filter1Json = new FiltersEncoder(filter1).encode(); - System.out.println("\nFilter 1 encoded separately:"); - System.out.println(filter1Json); + Filters proofFilter = new Filters(List.of( + new KindFilter<>(Kind.WALLET_UNSPENT_PROOF), + new AuthorFilter<>(new PublicKey(TEST_PUBKEY)) + )); - String filter2Json = new FiltersEncoder(filter2).encode(); - System.out.println("\nFilter 2 encoded separately:"); - System.out.println(filter2Json); + ReqMessage req = new ReqMessage("nip60-sync", List.of(walletFilter, proofFilter)); + String reqJson = req.encode(); assertNotNull(reqJson); - assertNotNull(filter1Json); - assertNotNull(filter2Json); + // REQ message format: ["REQ", , , , ...] + assertTrue(reqJson.startsWith("[\"REQ\""), "Should start with REQ command"); + assertTrue(reqJson.contains("\"nip60-sync\""), "Should contain subscription ID"); + assertTrue(reqJson.contains("17375"), "Should contain WALLET kind"); + assertTrue(reqJson.contains("7375"), "Should contain WALLET_UNSPENT_PROOF kind"); } } From adbee1d9d4e3bddebb188d3e1cdfd522a4381045 Mon Sep 17 00:00:00 2001 From: tcheeric Date: Wed, 21 Jan 2026 03:36:08 +0000 Subject: [PATCH 16/21] fix(ws): introduce PendingRequest for thread-safe request handling Replaced responseFuture and events with a thread-safe PendingRequest encapsulating CompletableFuture and event list. Improved response handling, added termination signal checks, and enhanced error handling for WebSocket communication. --- .../StandardWebSocketClient.java | 94 ++++++++++++++----- 1 file changed, 73 insertions(+), 21 deletions(-) diff --git a/nostr-java-client/src/main/java/nostr/client/springwebsocket/StandardWebSocketClient.java b/nostr-java-client/src/main/java/nostr/client/springwebsocket/StandardWebSocketClient.java index c65d860d..3cad918b 100644 --- a/nostr-java-client/src/main/java/nostr/client/springwebsocket/StandardWebSocketClient.java +++ b/nostr-java-client/src/main/java/nostr/client/springwebsocket/StandardWebSocketClient.java @@ -57,11 +57,40 @@ public class StandardWebSocketClient extends TextWebSocketHandler implements Web private final WebSocketSession clientSession; private final Object sendLock = new Object(); - private List events = new ArrayList<>(); - private CompletableFuture> responseFuture; + private PendingRequest pendingRequest; private final Map listeners = new ConcurrentHashMap<>(); private final AtomicBoolean connectionClosed = new AtomicBoolean(false); + /** Encapsulates a pending request's future and its associated events list for thread isolation. */ + private static final class PendingRequest { + private final CompletableFuture> future = new CompletableFuture<>(); + private final List events = new ArrayList<>(); + + void addEvent(String event) { + events.add(event); + } + + void complete() { + future.complete(List.copyOf(events)); + } + + void completeExceptionally(Throwable ex) { + future.completeExceptionally(ex); + } + + boolean isDone() { + return future.isDone(); + } + + CompletableFuture> getFuture() { + return future; + } + + int eventCount() { + return events.size(); + } + } + /** * Creates a new {@code StandardWebSocketClient} connected to the provided relay URI. * @@ -130,22 +159,47 @@ protected void handleTextMessage(@NonNull WebSocketSession session, TextMessage log.debug("Relay payload received: {}", message.getPayload()); dispatchMessage(message.getPayload()); synchronized (sendLock) { - if (responseFuture != null && !responseFuture.isDone()) { - events.add(message.getPayload()); - // Complete the future with the current events - instant notification - responseFuture.complete(List.copyOf(events)); - log.debug("Response future completed with {} events", events.size()); + if (pendingRequest != null && !pendingRequest.isDone()) { + pendingRequest.addEvent(message.getPayload()); + // Complete on termination signals: EOSE (end of stored events) or OK (event acceptance) + if (isTerminationMessage(message.getPayload())) { + pendingRequest.complete(); + log.debug("Response future completed with {} events", pendingRequest.eventCount()); + } } } } + /** + * Checks if the message is a Nostr protocol termination signal. + * + *

Termination signals indicate the relay has finished sending responses: + *

    + *
  • EOSE - End of Stored Events, sent after all matching events for a REQ
  • + *
  • OK - Acknowledgment of an EVENT submission
  • + *
  • NOTICE - Server notice (often indicates errors)
  • + *
  • CLOSED - Subscription closed by relay
  • + *
+ */ + private boolean isTerminationMessage(String payload) { + if (payload == null || payload.length() < 2) { + return false; + } + // Quick check for JSON array starting with known termination commands + // Format: ["EOSE", ...] or ["OK", ...] or ["NOTICE", ...] or ["CLOSED", ...] + return payload.startsWith("[\"EOSE\"") + || payload.startsWith("[\"OK\"") + || payload.startsWith("[\"NOTICE\"") + || payload.startsWith("[\"CLOSED\""); + } + @Override public void handleTransportError(@NonNull WebSocketSession session, @NonNull Throwable exception) { log.warn("Transport error on WebSocket session", exception); notifyError(exception); synchronized (sendLock) { - if (responseFuture != null && !responseFuture.isDone()) { - responseFuture.completeExceptionally(exception); + if (pendingRequest != null && !pendingRequest.isDone()) { + pendingRequest.completeExceptionally(exception); } } } @@ -158,8 +212,8 @@ public void afterConnectionClosed(@NonNull WebSocketSession session, @NonNull Cl notifyClose(); } synchronized (sendLock) { - if (responseFuture != null && !responseFuture.isDone()) { - responseFuture.completeExceptionally( + if (pendingRequest != null && !pendingRequest.isDone()) { + pendingRequest.completeExceptionally( new IOException("WebSocket connection closed: " + status)); } } @@ -172,12 +226,11 @@ public List send(T eventMessage) throws IOExcept @Override public List send(String json) throws IOException { - CompletableFuture> future; + PendingRequest request; synchronized (sendLock) { - events = new ArrayList<>(); - responseFuture = new CompletableFuture<>(); - future = responseFuture; + request = new PendingRequest(); + pendingRequest = request; log.info("Sending request to relay {}: {}", clientSession.getUri(), json); clientSession.sendMessage(new TextMessage(json)); } @@ -186,16 +239,15 @@ public List send(String json) throws IOException { log.debug("Waiting for relay response with timeout={}ms (event-driven)", timeout); try { - List result = future.get(timeout, TimeUnit.MILLISECONDS); + List result = request.getFuture().get(timeout, TimeUnit.MILLISECONDS); log.info("Received {} relay events via {}", result.size(), clientSession.getUri()); return result; } catch (TimeoutException e) { log.error("Timed out waiting for relay response after {}ms", timeout); synchronized (sendLock) { - if (responseFuture == future) { - responseFuture = null; + if (pendingRequest == request) { + pendingRequest = null; } - events = new ArrayList<>(); } try { clientSession.close(); @@ -214,8 +266,8 @@ public List send(String json) throws IOException { throw new IOException("Error waiting for relay response", cause); } finally { synchronized (sendLock) { - if (responseFuture == future) { - responseFuture = null; + if (pendingRequest == request) { + pendingRequest = null; } } } From 1e685e9c87a454f6ab442d560abbce02bb7b8e0c Mon Sep 17 00:00:00 2001 From: tcheeric Date: Wed, 21 Jan 2026 03:42:49 +0000 Subject: [PATCH 17/21] fix(ws): improve WebSocket client response handling and thread-safety --- CHANGELOG.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29adfa2f..64dc98a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,8 +12,13 @@ No unreleased changes yet. ### Fixed - NIP-44 now correctly uses HKDF-Extract for conversation key derivation, ensuring proper cryptographic key generation. -- Kind enum error handling improved for unknown kind values to prevent runtime exceptions. -- WebSocket client response handling refactored to use CompletableFuture for event-driven notifications, improving reliability and responsiveness. +- WebSocket client now correctly accumulates all relay responses (EVENT messages) before completing, waiting for termination signals (EOSE, OK, NOTICE, CLOSED) instead of returning after the first message. +- WebSocket client thread-safety improved by encapsulating pending request state, preventing potential race conditions when multiple threads call send() concurrently. + +### Changed +- Kind.valueOf(int) now returns null for unknown kind values instead of throwing, allowing graceful handling of custom or future NIP kinds during JSON deserialization. +- Added Kind.valueOfStrict(int) for callers who need fail-fast behavior on unknown kinds. +- Added Kind.findByValue(int) returning Optional for safe, explicit handling of unknown kinds. ## [1.2.0] - 2025-12-26 From 4963e6e56e9308d7f099a7b977b56cc020822195 Mon Sep 17 00:00:00 2001 From: tcheeric Date: Wed, 21 Jan 2026 13:01:52 +0000 Subject: [PATCH 18/21] fix(ws): reject concurrent send() calls, enhance deserialization with fail-fast checks --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 64dc98a8..a6c6a38c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ No unreleased changes yet. - NIP-44 now correctly uses HKDF-Extract for conversation key derivation, ensuring proper cryptographic key generation. - WebSocket client now correctly accumulates all relay responses (EVENT messages) before completing, waiting for termination signals (EOSE, OK, NOTICE, CLOSED) instead of returning after the first message. - WebSocket client thread-safety improved by encapsulating pending request state, preventing potential race conditions when multiple threads call send() concurrently. +- WebSocket client now rejects concurrent send() calls with IllegalStateException instead of silently orphaning the previous request's future. +- KindFilter and ClassifiedListingEventDeserializer now use Kind.valueOfStrict() for fail-fast deserialization of unknown kind values. ### Changed - Kind.valueOf(int) now returns null for unknown kind values instead of throwing, allowing graceful handling of custom or future NIP kinds during JSON deserialization. From a91fee9b741162582d6bf582f591c1680cb22752 Mon Sep 17 00:00:00 2001 From: tcheeric Date: Wed, 21 Jan 2026 13:02:04 +0000 Subject: [PATCH 19/21] fix(Kind): use valueOfStrict for improved error handling in KindFilter --- .../src/main/java/nostr/event/filter/KindFilter.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nostr-java-event/src/main/java/nostr/event/filter/KindFilter.java b/nostr-java-event/src/main/java/nostr/event/filter/KindFilter.java index 09d4f869..89ca57ef 100644 --- a/nostr-java-event/src/main/java/nostr/event/filter/KindFilter.java +++ b/nostr-java-event/src/main/java/nostr/event/filter/KindFilter.java @@ -39,5 +39,5 @@ private T getKind() { } public static Function fxn = - node -> new KindFilter<>(Kind.valueOf(node.asInt())); + node -> new KindFilter<>(Kind.valueOfStrict(node.asInt())); } From a2d9242418bc2ffba61aa2d98ad41becd265a5c2 Mon Sep 17 00:00:00 2001 From: tcheeric Date: Wed, 21 Jan 2026 13:02:14 +0000 Subject: [PATCH 20/21] fix(ws): prevent concurrent send() calls in StandardWebSocketClient --- .../client/springwebsocket/StandardWebSocketClient.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/nostr-java-client/src/main/java/nostr/client/springwebsocket/StandardWebSocketClient.java b/nostr-java-client/src/main/java/nostr/client/springwebsocket/StandardWebSocketClient.java index 3cad918b..5167fb36 100644 --- a/nostr-java-client/src/main/java/nostr/client/springwebsocket/StandardWebSocketClient.java +++ b/nostr-java-client/src/main/java/nostr/client/springwebsocket/StandardWebSocketClient.java @@ -229,6 +229,11 @@ public List send(String json) throws IOException { PendingRequest request; synchronized (sendLock) { + if (pendingRequest != null && !pendingRequest.isDone()) { + throw new IllegalStateException( + "A request is already in flight. Concurrent send() calls are not supported. " + + "Wait for the current request to complete or use separate client instances."); + } request = new PendingRequest(); pendingRequest = request; log.info("Sending request to relay {}: {}", clientSession.getUri(), json); From abb5ec9ef5e1f881f5e1ee7468cb5c653043d97c Mon Sep 17 00:00:00 2001 From: tcheeric Date: Wed, 21 Jan 2026 13:02:20 +0000 Subject: [PATCH 21/21] fix(ws): prevent concurrent send() calls in StandardWebSocketClient --- .../json/deserializer/ClassifiedListingEventDeserializer.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nostr-java-event/src/main/java/nostr/event/json/deserializer/ClassifiedListingEventDeserializer.java b/nostr-java-event/src/main/java/nostr/event/json/deserializer/ClassifiedListingEventDeserializer.java index e42af878..377137e9 100644 --- a/nostr-java-event/src/main/java/nostr/event/json/deserializer/ClassifiedListingEventDeserializer.java +++ b/nostr-java-event/src/main/java/nostr/event/json/deserializer/ClassifiedListingEventDeserializer.java @@ -44,7 +44,7 @@ public ClassifiedListingEvent deserialize(JsonParser jsonParser, Deserialization ClassifiedListingEvent classifiedListingEvent = new ClassifiedListingEvent( new PublicKey(generalMap.get("pubkey")), - Kind.valueOf(Integer.parseInt(generalMap.get("kind"))), + Kind.valueOfStrict(Integer.parseInt(generalMap.get("kind"))), baseTags, generalMap.get("content")); classifiedListingEvent.setId(generalMap.get("id"));