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/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..945efe36 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,260 @@ +# 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 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 + +- 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/CHANGELOG.md b/CHANGELOG.md index e2563397..64dc98a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,29 @@ 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. + +### 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/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..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..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 @@ -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,50 @@ 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); + 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/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..2d8eab21 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ xyz.tcheeric nostr-java - 1.1.1 + 1.2.1 pom nostr-java