This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
nostr-java is a Java SDK for the Nostr protocol. It provides utilities for creating, signing, and publishing Nostr events to relays.
- Language: Java 21+
- Build Tool: Maven
- Architecture: Multi-module Maven project with 4 modules
The codebase follows a layered dependency structure:
- nostr-java-core – Foundation utilities and BIP340 Schnorr cryptography (packages:
nostr.util,nostr.crypto) - nostr-java-event – Event model, tags, filters, serialization, and base types (packages:
nostr.event,nostr.base) - nostr-java-identity – Identity/key management and encryption (packages:
nostr.id,nostr.encryption) - nostr-java-client – WebSocket relay client with Spring support (packages:
nostr.client)
Dependency chain: core → event → identity → client
Key principle: Lower-level modules cannot depend on higher-level ones. When adding features, place code at the lowest appropriate level.
# Run all unit tests (no Docker required)
mvn clean test
# Run integration tests (requires Docker for Testcontainers)
mvn clean verify
# Install artifacts without tests
mvn install -Dmaven.test.skip=true
# Run a specific test class
mvn -q test -Dtest=GenericEventBuilderTest# 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- GenericEvent is the single event class for all Nostr event kinds
- Events use
int kindvalues; common kinds defined as constants inKindsutility class - Events can be built using:
- Direct constructors with
PublicKeyandint kind - Static
GenericEvent.builder()for flexible construction
- Direct constructors with
- All events must be signed before sending to relays
- GenericTag is the single tag class, holding
code+List<String> params - Factory:
GenericTag.of("p", pubkeyHex, relayUrl)orBaseTag.create("e", eventId) - Serialized as JSON arrays:
["code", "param0", "param1", ...]
- EventFilter with builder pattern for composable query filters
- Supports
ids,authors,kinds,since,until,limit, and tag filters via.addTagFilter() - Filters holds a
List<EventFilter>for REQ messages
- NostrRelayClient – Blocking send with configurable timeout, streaming subscribe, Spring Retry (3 attempts, exponential backoff)
- Throws
RelayTimeoutExceptionon timeout (instead of returning empty list) - Tracks
ConnectionState(CONNECTING, CONNECTED, RECONNECTING, CLOSED)
Configuration properties:
nostr.websocket.await-timeout-ms=60000nostr.websocket.max-idle-timeout-ms=3600000nostr.websocket.max-events-per-request=10000
Identityclass manages key pairs- Events implement
ISignableinterface - Signing uses Schnorr signatures (BIP340)
- Public keys use Bech32 encoding (npub prefix)
- Unit tests (
*Test.java): No external dependencies, use mocks - Integration tests (
*IT.java): Use Testcontainers to startnostr-rs-relay - Integration tests may be retried once on failure (configured in failsafe plugin)
- 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.mdfor full guidelines
- Allowed types:
- PR target: All PRs should target the
developbranch - 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
- BOM:
nostr-java-bommanages all dependency versions - Root
pom.xmlincludes temporary module version overrides until next BOM release - Never add version numbers to dependencies in child modules – let the BOM manage versions
// Using builder
GenericEvent event = GenericEvent.builder()
.kind(Kinds.TEXT_NOTE)
.content("content")
.pubKey(publicKey)
.build();
// Using constructor
GenericEvent event = new GenericEvent(pubKey, Kinds.TEXT_NOTE);// Create tags
GenericTag tag = GenericTag.of("p", pubkeyHex, "wss://relay.example.com");
GenericTag hashtag = GenericTag.of("t", "nostr");// Build a filter
EventFilter filter = EventFilter.builder()
.kind(Kinds.TEXT_NOTE)
.author(pubkeyHex)
.since(timestamp)
.limit(100)
.build();Spring WebSocket client maintains persistent connections. Always close subscriptions properly to avoid resource leaks.
This project uses Java 21 Virtual Threads (Project Loom) for efficient concurrency. Virtual Threads are enabled by default via spring.threads.virtual.enabled=true in the gateway. Always prefer Virtual Threads over platform threads for I/O-bound work.
| Scenario | Use Virtual Threads? | Pattern |
|---|---|---|
| Mint API calls (mint, melt, swap) | Yes | CompletableFuture with VT executor |
| Database queries (gateway) | Yes | Parallel queries with VT executor |
| Nostr relay operations | Yes | Parallel publish/fetch across relays |
| Nostrdb queries | Yes | VT handles LMDB blocking efficiently |
| SSE event delivery | Yes | Spring WebFlux handles this automatically |
| File I/O (wallet storage) | Yes | VT handles blocking efficiently |
| Cryptographic operations (signing) | No | CPU-bound, use parallel streams |
| Quick in-memory operations | No | Overhead not justified |
1. Parallel I/O with CompletableFuture and VT Executor (Preferred)
Use when you need results from multiple independent I/O operations:
import java.util.concurrent.*;
// Parallel mint API queries with Virtual Threads
private List<QuoteStatus> fetchQuoteStatuses(List<String> quoteIds, MintClient mintClient) {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<CompletableFuture<QuoteStatus>> futures = quoteIds.stream()
.map(id -> CompletableFuture.supplyAsync(() -> {
return mintClient.getMintQuoteStatus(id);
}, executor))
.toList();
// Wait for all futures to complete
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
return futures.stream()
.map(f -> f.getNow(null))
.filter(Objects::nonNull)
.toList();
}
}2. Parallel Nostr Relay Operations
Use when publishing or fetching from multiple relays:
// Publish event to multiple relays in parallel
private Map<String, Boolean> publishToRelays(NostrEvent event, List<String> relayUrls) {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
Map<String, CompletableFuture<Boolean>> futures = new ConcurrentHashMap<>();
for (String relayUrl : relayUrls) {
futures.put(relayUrl, CompletableFuture.supplyAsync(() -> {
try {
return nostrClient.publish(relayUrl, event);
} catch (Exception e) {
log.warn("Failed to publish to {}: {}", relayUrl, e.getMessage());
return false;
}
}, executor));
}
CompletableFuture.allOf(futures.values().toArray(new CompletableFuture[0])).join();
return futures.entrySet().stream()
.collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().getNow(false)));
}
}3. Fire-and-Forget with @Async
Use for event handlers that shouldn't block the caller:
@Async // Runs on VT via AsyncConfig
@EventListener
public void onWalletUpdate(WalletUpdateEvent event) {
// Sync to Nostr in background
nostrSyncService.syncWalletState(event.getWalletId());
}4. Parallel Database Queries in Gateway
Use for fetching related entities:
// Parallel fetch of user data from multiple tables
private UserProfile loadFullProfile(String pubkey) {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var profileFuture = CompletableFuture.supplyAsync(
() -> profileRepository.findByPubkey(pubkey), executor);
var walletsFuture = CompletableFuture.supplyAsync(
() -> walletRepository.findByOwnerPubkey(pubkey), executor);
var settingsFuture = CompletableFuture.supplyAsync(
() -> settingsRepository.findByPubkey(pubkey), executor);
CompletableFuture.allOf(profileFuture, walletsFuture, settingsFuture).join();
return UserProfile.builder()
.profile(profileFuture.getNow(null))
.wallets(walletsFuture.getNow(List.of()))
.settings(settingsFuture.getNow(null))
.build();
}
}5. Parallel Mint Swaps
Use when swapping tokens across multiple mints:
// Parallel swaps when consolidating tokens from multiple mints
private List<SwapResult> parallelSwap(List<SwapRequest> requests) {
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
List<CompletableFuture<SwapResult>> futures = requests.stream()
.map(req -> CompletableFuture.supplyAsync(() -> {
try {
return mintClient.swap(req.getMintUrl(), req.getProofs(), req.getOutputs());
} catch (Exception e) {
return SwapResult.failed(req.getMintUrl(), e.getMessage());
}
}, executor))
.toList();
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
return futures.stream()
.map(f -> f.getNow(SwapResult.failed("unknown", "timeout")))
.toList();
}
}❌ Sequential I/O in loops when items are independent:
// BAD: Sequential blocking calls
for (String mintUrl : mintUrls) {
var keysets = mintClient.getKeysets(mintUrl); // Blocks
// ...
}❌ Using synchronized for I/O operations (causes VT pinning):
// BAD: Pins virtual thread to carrier thread
synchronized (lock) {
database.query(...); // Pinned during entire I/O!
}
// GOOD: Use ReentrantLock instead
private final ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
database.query(...); // VT can unmount during I/O
} finally {
lock.unlock();
}❌ Creating platform thread pools for I/O work:
// BAD: Wastes platform threads on I/O
ExecutorService pool = Executors.newFixedThreadPool(10);
// GOOD: Use virtual thread executor
ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor();❌ Blocking the SSE thread:
// BAD: Blocks SSE connection during mint call
@GetMapping(value = "/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<Event> events() {
var status = mintClient.checkStatus(...); // Blocks!
return Flux.just(Event.of(status));
}
// GOOD: Use reactive operators
@GetMapping(value = "/events", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<Event> events() {
return Mono.fromCallable(() -> mintClient.checkStatus(...))
.subscribeOn(Schedulers.boundedElastic())
.map(Event::of)
.flux();
}| Component | Configuration | Purpose |
|---|---|---|
| Spring Boot | spring.threads.virtual.enabled=true |
Use VT for request handling |
| Tomcat | server.tomcat.threads.max=50 |
Reduced (VTs handle concurrency) |
@Async |
AsyncConfig bean |
VT executor for async methods |
| HTTP Client | JdkClientHttpRequestFactory |
VT-friendly HTTP client |
# Enable VT debugging output
-Djdk.tracePinnedThreads=full
# Check for pinning in logs
grep -i "pinned" logs/application.log
# Monitor virtual thread count
jcmd <pid> Thread.dump_to_file -format=json threads.json