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) 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/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: 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 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index e2563397..a6c6a38c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,31 @@ 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. +- 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. +- 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 + +### 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-api/pom.xml b/nostr-java-api/pom.xml index 2161d106..12d640e3 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.1 ../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..3749b8c6 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.1 ../pom.xml 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..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,8 +90,42 @@ public static Kind valueOf(int value) { return k; } } + return null; + } - return TEXT_NOTE; + /** + * 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 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()) { diff --git a/nostr-java-client/pom.xml b/nostr-java-client/pom.xml index b2da8b3b..be230114 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.1 ../pom.xml 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..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 @@ -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,19 +50,47 @@ 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 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. * @@ -80,7 +115,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 +131,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,20 +159,48 @@ protected void handleTextMessage(@NonNull WebSocketSession session, TextMessage log.debug("Relay payload received: {}", message.getPayload()); dispatchMessage(message.getPayload()); synchronized (sendLock) { - if (awaitingResponse) { - events.add(message.getPayload()); - completed.setRelease(true); + 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: + *

+ */ + 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) { - awaitingResponse = false; - completed.setRelease(true); + if (pendingRequest != null && !pendingRequest.isDone()) { + pendingRequest.completeExceptionally(exception); + } } } @@ -149,8 +212,10 @@ public void afterConnectionClosed(@NonNull WebSocketSession session, @NonNull Cl notifyClose(); } synchronized (sendLock) { - awaitingResponse = false; - completed.setRelease(true); + if (pendingRequest != null && !pendingRequest.isDone()) { + pendingRequest.completeExceptionally( + new IOException("WebSocket connection closed: " + status)); + } } } @@ -161,43 +226,55 @@ public List send(T eventMessage) throws IOExcept @Override public List send(String json) throws IOException { + PendingRequest request; + synchronized (sendLock) { - events = new ArrayList<>(); - awaitingResponse = true; - completed.setRelease(false); - log.info("Sending subscription request to relay {}: {}", clientSession.getUri(), json); + 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); 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 = 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 (pendingRequest == request) { + pendingRequest = null; + } + } 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 (pendingRequest == request) { + pendingRequest = 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; } } diff --git a/nostr-java-crypto/pom.xml b/nostr-java-crypto/pom.xml index b07dd3b6..43b73675 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.1 ../pom.xml 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..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 @@ -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,8 @@ import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; +import java.security.InvalidKeyException; import java.security.NoSuchAlgorithmException; -import java.security.spec.InvalidKeySpecException; -import java.security.spec.KeySpec; import java.util.Arrays; import java.util.Base64; @@ -98,8 +95,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 +121,19 @@ 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"); - - return factory.generateSecret(keySpec).getEncoded(); + // NIP-44: conversation_key = HKDF-Extract(salt="nip44-v2", ikm=shared_x) + // 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) { @@ -237,17 +237,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 +273,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/pom.xml b/nostr-java-encryption/pom.xml index 099cd082..d7c8c9f7 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.1 ../pom.xml 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() { diff --git a/nostr-java-event/pom.xml b/nostr-java-event/pom.xml index 6c2d1bc9..11f1435a 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.1 ../pom.xml 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())); } 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")); 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..368e82b1 --- /dev/null +++ b/nostr-java-event/src/test/java/nostr/event/unit/Nip60FilterJsonTest.java @@ -0,0 +1,89 @@ +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; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Test to verify NIP-60 filter JSON serialization format. + */ +class Nip60FilterJsonTest { + + private static final String TEST_PUBKEY = "f1b419a95cb0233a11d431423b41a42734e7165fcab16081cd08ef1c90e0be75"; + + @Test + 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)) + )); + + 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)) + )); + + String filterJson = new FiltersEncoder(filter).encode(); + + 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)) + )); + + Filters proofFilter = new Filters(List.of( + new KindFilter<>(Kind.WALLET_UNSPENT_PROOF), + new AuthorFilter<>(new PublicKey(TEST_PUBKEY)) + )); + + ReqMessage req = new ReqMessage("nip60-sync", List.of(walletFilter, proofFilter)); + String reqJson = req.encode(); + + assertNotNull(reqJson); + // 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"); + } +} diff --git a/nostr-java-examples/pom.xml b/nostr-java-examples/pom.xml index 77fc8235..571c8657 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.1 ../pom.xml diff --git a/nostr-java-id/pom.xml b/nostr-java-id/pom.xml index 05b12336..100969d7 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.1 ../pom.xml diff --git a/nostr-java-util/pom.xml b/nostr-java-util/pom.xml index 612b7825..4efd630a 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.1 ../pom.xml diff --git a/pom.xml b/pom.xml index 1ae2d671..c5327905 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ xyz.tcheeric nostr-java - 1.1.1 + 1.2.1 pom nostr-java @@ -84,7 +84,7 @@ 1.1.8 - 0.9.0 + 0.10.0 3.3.1 3.11.3 3.2.8