openclaw-nostr
v2026.7.38
Published
Nostr channel plugin for OpenClaw
Readme
openclaw-nostr
Nostr channel plugin for OpenClaw — encrypted DMs, public notes, identity resolution, relay discovery, public channels, and 20+ NIP implementations.
Overview
This extension adds Nostr as a full-featured messaging and social channel to OpenClaw. It enables your agent to:
- Receive and send encrypted DMs via NIP-04 and NIP-17 (gift-wrapped)
- Publish and react to public notes (kind:1)
- Resolve NIP-05 identities and discover relay capabilities
- Create and moderate public channels (NIP-28)
- Repost content, publish file metadata, and generate zap requests
- Encrypt messages with NIP-44 versioned encryption
- Authenticate to relays (NIP-42) and HTTP services (NIP-98)
- Delegate signing to a remote NIP-46 bunker (no local private key needed)
- Access 20+ additional NIP implementations from
nostr-tools
Installation
Normal install
openclaw plugins install npm:openclaw-nostropenclaw-nostr is a runtime-only plugin artifact. It contains no bootstrap
scripts or process-spawning code, so OpenClaw's install-time security scanner can
scan and install it normally. The OpenClaw plugin id remains nostr.
Optional legacy migration
If you previously installed the broken @openclaw/nostr, the old combined
nostr-claw-bootstrap package, or a direct-copy override, run:
npx openclaw-nostr-bootstrapThe migration CLI:
- detects the host OpenClaw version
- removes recognized stale direct-copy/activation installs and broken managed npm entries left by prior packages
- runs
openclaw plugins install npm:openclaw-nostr --force - enables the plugin in
openclaw.json - refreshes the persisted plugin registry (via the install)
- runs a best-effort plugin-graph smoke test
For an offline migration, install from a local plugin checkout with
--plugin-source:
npx openclaw-nostr-bootstrap --plugin-source /path/to/openclaw-nostrWith an explicit OpenClaw checkout / CLI path:
npx openclaw-nostr-bootstrap --openclaw /path/to/openclaw.mjsFor machine-readable output:
npx openclaw-nostr-bootstrap --jsonFrom this repo (Cascadia fork)
git clone https://git.sharegap.net/cascadia/openclaw-nostr.gitSee Docker Deployment for containerized setups.
Cascadia Fleet Role
This repo is the durable maintenance layer for Cascadia's Nostr fixes when upstream OpenClaw upgrades clobber local agent installs.
The repository now publishes two deliberately separate packages:
openclaw-nostr: the runtime-only OpenClaw plugin and normal install artifactopenclaw-nostr-bootstrap: an optional migration CLI for legacy installs
Upstream OpenClaw remains the base runtime, while this repo is the source of truth for Nostr-specific durability patches and extended features.
See:
docs/UPGRADE-WORKFLOW.mddocs/COMPATIBILITY.mdscripts/apply-to-agent.shscripts/check-agent.sh
Publishing (maintainers)
Two independent npm packages are published from this repo. They share a version but are released separately.
1. The plugin — openclaw-nostr (runtime-only artifact users install):
# from the repo root
npm login # once, if not already authenticated
npm publish --dry-run # inspect the tarball: no scripts/, cascadia-ts bundled, 0 child_process
npm publish --access public # publish openclaw-nostr@<version>The published tarball is runtime-only (files[] excludes scripts/) and bundles
cascadia-ts via bundledDependencies, so it installs and passes OpenClaw's
install-time security scanner cleanly. Verify before publishing:
npm pack # -> openclaw-nostr-<version>.tgz
npm run test:scanner # asserts 0 critical scanner findings
# optional clean-room install check:
mkdir /tmp/on-verify && cd /tmp/on-verify && npm init -y >/dev/null
npm install /path/to/openclaw-nostr-<version>.tgz # must resolve cascadia-ts from the bundle2. The migration CLI — openclaw-nostr-bootstrap (optional, npx-run):
cd packages/openclaw-nostr-bootstrap
npm publish --dry-run
npm publish --access public # publish openclaw-nostr-bootstrap@<version>This package intentionally ships scripts/ (which spawn child_process). That is
fine — it is an npx CLI, never installed as an OpenClaw plugin, so the plugin
scanner never runs against it.
Publishing to a private/self-hosted registry instead of public npm: add
--registry https://<registry>to eachnpm publish, or setpublishConfig.registryin the respectivepackage.json.
Bump the version in both package.json files before a coordinated release
(the plugin at the repo root and packages/openclaw-nostr-bootstrap/package.json).
Quick Setup
Generate a Nostr keypair (if you don't have one):
# Using nak CLI nak key generate # Or use any Nostr key generatorAdd to your config (
~/.openclaw/openclaw.json):{ "channels": { "nostr": { "privateKey": "${NOSTR_PRIVATE_KEY}", "relays": ["wss://relay.sharegap.net", "wss://nos.lol"] } } }Set the environment variable:
export NOSTR_PRIVATE_KEY="nsec1..." # or 64-char hex formatRestart the gateway
Configuration
| Key | Type | Default | Description |
| ------------ | -------- | ------------------------------------------- | ---------------------------------------------------------- |
| privateKey | string | required* | Bot's private key (nsec or hex). *Not required when using NIP-46. |
| relays | string[] | ["wss://relay.sharegap.net", "wss://nos.lol"] | WebSocket relay URLs |
| dmPolicy | string | "pairing" | Access control: pairing, allowlist, open, disabled |
| allowFrom | string[] | [] | Allowed sender pubkeys (npub or hex) |
| enabled | boolean | true | Enable/disable the channel |
| name | string | - | Display name for the account |
NIP-46 Remote Signing
When NIP-46 is enabled, event signing and encryption/decryption are delegated to a remote signer (bunker). No private key is stored locally — only a client secret used for the encrypted communication channel.
| Key | Type | Default | Description |
| ------------------------ | -------- | --------- | ------------------------------------------------------------------ |
| nip46 | boolean | false | Enable NIP-46 remote signing |
| nip46BunkerUrl | string | - | bunker:// URL or user@domain NIP-05 identifier |
| nip46SignerRelays | string[] | - | Relay URLs for signer communication (defaults to bunker URL relays) |
| nip46Secret | string | - | Client secret key (hex) — use env var, not raw config |
| nip46ConnectionTimeoutMs | number | 60000 | Connection timeout for the NIP-46 session |
NIP-46 Setup
Set up a bunker signer (e.g. nsecBunker, Amber, or any NIP-46 compatible signer)
Generate a client secret (a random 32-byte hex key for the encrypted channel):
# Generate a random client secret openssl rand -hex 32Store the client secret securely as an environment variable:
export NOSTR_NIP46_SECRET="your-64-char-hex-client-secret"Configure in
openclaw.json:{ "channels": { "nostr": { "nip46": true, "nip46BunkerUrl": "bunker://abcdef...?relay=wss://relay.nsecbunker.com", "relays": ["wss://relay.sharegap.net", "wss://nos.lol"] } } }Or with a NIP-05 bunker identifier:
{ "channels": { "nostr": { "nip46": true, "nip46BunkerUrl": "[email protected]" } } }Alternatively, use a
SecretRefto point to the env var explicitly:{ "channels": { "nostr": { "nip46": true, "nip46BunkerUrl": "bunker://abcdef...?relay=wss://relay.nsecbunker.com", "nip46Secret": { "source": "env", "provider": "default", "id": "NOSTR_NIP46_SECRET" } } } }
Secret Sources
The nip46Secret field supports three SecretRef source types for production deployments:
| Source | Example | Description |
| ------ | ------- | ----------- |
| env | { "source": "env", "name": "NOSTR_NIP46_SECRET" } | Read from environment variable (id or name field) |
| file | { "source": "file", "path": "/run/secrets/nip46" } | Read from a file (Docker secrets, tmpfs, etc.) |
| exec | { "source": "exec", "command": "vault kv get -field=secret nostr/nip46" } | Run a command, use stdout (10s timeout) |
All sources fall back to the NOSTR_NIP46_SECRET environment variable if the primary source fails.
Enrollment Flow
The plugin provides a programmatic enrollment flow (enrollNip46Signer()) that automates the full NIP-46 setup ceremony:
- Generates a client secret (or reuses an existing one)
- Validates and parses the bunker URL
- Connects to the remote signer
- Verifies the remote pubkey matches expectations
- Runs a self-test on all delegated crypto operations
- Produces a config patch and env var instructions
Cutover Migration
To migrate from local key to NIP-46 remote signing, use performCutover() which executes a transactional migration:
- Validates a local private key exists
- Snapshots the current config as backup
- Runs the enrollment flow (connect, verify, self-test)
- Applies the new config (enables NIP-46, removes
privateKey) - Runs post-cutover verification
- Automatically rolls back to the original config if any step fails
Preflight Doctor
The runNip46Doctor() function performs 7 diagnostic checks:
| Check | What it verifies |
| ----- | ---------------- |
| config_valid | Bunker URL and client secret are provided |
| bunker_url_parsed | URL yields a pubkey (or is valid NIP-05) |
| client_secret_decodable | Hex secret decodes to 32 bytes |
| signer_connect | BunkerSigner connects within timeout |
| pubkey_match | get_public_key matches expected identity |
| crypto_self_test | sign, nip44, nip04 all work end-to-end |
| secret_recoverable | Client secret re-decodes consistently (restart safety) |
What Gets Delegated
When NIP-46 is active, all identity-level cryptographic operations go through the remote signer:
- Event signing (DMs, notes, reactions, deletions, reposts, channel messages, group messages)
- NIP-04 encryption and decryption
- NIP-44 encryption and decryption (used in NIP-17 gift wraps)
- NIP-42 relay authentication
- NIP-98 HTTP authentication
The client secret (nip46Secret) is only used to establish the NIP-44 encrypted channel with the bunker — it is not the identity private key.
Startup Self-Test & Health Logging
On startup with NIP-46 enabled, the plugin:
- Logs a signer health block showing signing mode, identity pubkey, bunker URL, relay list, and session status
- Runs an automatic self-test exercising all delegated crypto operations:
get_public_key— returns valid 64-char hex pubkey matching expected identitysign_event— signs a test kind:1 eventnip44_encrypt_decrypt— round-trips NIP-44 encryptionnip04_encrypt_decrypt— round-trips NIP-04 encryption
- Tracks runtime health (success/failure counts, last sign/decrypt/encrypt timestamps)
Failures are logged as warnings — the bus still starts, but you'll know which operations aren't working.
Custody Status
The resolveCustodyStatus() function reports the current signing posture:
- Signing mode:
local-key,nip46-remote, orunconfigured - Key residency: whether a local private key still exists in config
- NIP-46 readiness: bunker URL + secret configured
- Runtime health: signer success/failure counts and last operation timestamps
- Restart safety: whether the secret source will survive a restart
- Warnings: actionable alerts (e.g. "local key still present after NIP-46 migration")
Rate-Limit Protection
NIP-46 operations go through a request queue with:
- Concurrency limit (default: 2 concurrent requests)
- Stagger interval (default: 150ms between dispatches)
- Exponential backoff retry on rate-limit errors (1s → 2s → 4s, up to 3 retries)
This prevents bursts of inbound messages from overwhelming the bunker relay with rapid-fire publishes.
Outbound DM Safety Assertions
The NIP-17 outbound path includes runtime assertions that prevent routing bugs:
- Recipient pubkey must not equal own pubkey (no self-DM)
- Recipient pubkey must be valid 64-char hex
- Post-wrap
ptags must match the intended recipient for each wrap - Self-echo wraps are logged and ignored on the inbound path
Security Considerations
- Never commit the client secret to config files or version control
- The
NOSTR_NIP46_SECRETenv var is the recommended storage method - The
SecretRefmechanism supportsenv,file, andexecbackends for production deployments - The bunker must be online for the agent to sign events — plan for connectivity
Access Control
DM Policies
- pairing (default): Unknown senders receive a pairing code to request access
- allowlist: Only pubkeys in
allowFromcan message the bot - open: Anyone can message the bot (use with caution)
- disabled: DMs are disabled
Example: Allowlist Mode
{
"channels": {
"nostr": {
"privateKey": "${NOSTR_PRIVATE_KEY}",
"dmPolicy": "allowlist",
"allowFrom": ["npub1abc...", "0123456789abcdef..."]
}
}
}Protocol Support
Core Messaging (Tier 0)
| NIP | Kind(s) | Status | Description | | ------ | ------------ | ----------- | ------------------------------------- | | NIP-01 | 1 | ✅ Full | Basic event structure & public notes | | NIP-04 | 4 | ✅ Full | Encrypted DMs (legacy) | | NIP-09 | 5 | ✅ Full | Event deletion | | NIP-10 | — | ✅ Full | Thread references (root/reply/mention)| | NIP-17 | 1059 | ✅ Full | Gift-wrapped DMs (modern) | | NIP-25 | 7 | ✅ Full | Reactions | | NIP-40 | — | ✅ Full | Event expiration | | NIP-65 | 10002 | ✅ Full | Relay list metadata |
Tier 1 — Agent-Essential Features
| NIP | Kind(s) | Status | Description | | ------ | ------------ | ----------- | ------------------------------------- | | NIP-05 | — | ✅ Full | Identity resolution + domain search | | NIP-11 | — | ✅ Full | Relay information + capability checks | | NIP-42 | 22242 | ✅ Full | Relay authentication | | NIP-46 | 24133 | ✅ Full | Remote signing (Nostr Connect/Bunker) | | NIP-57 | 9734, 9735 | ✅ Re-export| Zaps (Lightning payments) | | NIP-94 | 1063 | ✅ Full | File metadata | | NIP-98 | 27235 | ✅ Full | HTTP authentication | | NIP-B7 | — | ✅ Re-export| Blossom media server |
Tier 2 — Social & Channel Features
| NIP | Kind(s) | Status | Description | | ------ | ------------------ | ----------- | ------------------------------- | | NIP-13 | — | ✅ Re-export| Proof of Work | | NIP-18 | 6, 16 | ✅ Full | Reposts (short text + generic) | | NIP-27 | — | ✅ Full | Content parsing (text/URLs/refs)| | NIP-28 | 40, 42, 43, 44 | ✅ Full | Public channels (CRUD + mod) | | NIP-44 | — | ✅ Full | Versioned encryption |
Tier 3 — Niche / Advanced (Namespace Re-exports)
| NIP | Module | Description |
| ------ | ------------------ | ------------------------------------- |
| NIP-29 | nostr-extras | Relay-based groups |
| NIP-30 | nostr-extras | Custom emoji |
| NIP-39 | nostr-extras | External identity verification |
| NIP-47 | nostr-extras | Nostr Wallet Connect (NWC) |
| NIP-49 | nostr-extras | Private key encryption (ncryptsec) |
| NIP-58 | nostr-extras | Badges |
| NIP-75 | nostr-extras | Zap goals (fundraising) |
| NIP-77 | nostr-extras | Negentropy sync |
Architecture
The plugin follows a three-layer architecture:
nostr-capabilities.ts ← Event builders (pure functions, no I/O)
nostr-discovery.ts ← NIP-05/NIP-11/NIP-27 (network I/O with caching)
nostr-extras.ts ← Tier 3 namespace re-exports
nip46-signer.ts ← NIP-46 signer abstraction (NostrSigner interface)
nip46-doctor.ts ← NIP-46 preflight diagnostics (7-check health report)
nip46-enroll.ts ← NIP-46 enrollment flow (generate secret, connect, verify)
nip46-cutover.ts ← NIP-46 migration (local key → remote signer, auto-rollback)
nip46-status.ts ← Custody status reporting (signing mode, health, warnings)
│
nostr-bus.ts ← Runtime wiring (signing, publishing, subscriptions)
│
channel.ts ← Public API (OpenClaw plugin interface)
│
nostr-profile-http.ts ← HTTP endpoints (/api/channels/nostr/...)Key Design Decisions
- Unsigned templates: All event builders return
EventTemplateobjects. The bus layer handles signing viafinalizeEvent(local) orNostrSigner.signEvent(NIP-46) and publishing via the pool. This keeps builders pure and testable. - NIP-46 signer abstraction: The
NostrSignerinterface (nip46-signer.ts) provides a unified API for signing, encryption, and decryption. When NIP-46 is enabled, all bus operations delegate to the remoteBunkerSignerthrough a request queue (rate-limit protection) with retry logic; otherwise they use the local secret key. The abstraction is transparent to upstream consumers. - NIP-46 lifecycle modules: Enrollment (
nip46-enroll.ts), cutover migration (nip46-cutover.ts), preflight diagnostics (nip46-doctor.ts), and custody status (nip46-status.ts) are standalone modules that can be called programmatically or wired into CLI commands. - NIP-28 custom builders: The upstream
nostr-tools/nip28functions callfinalizeEventinternally. We provide our own builders that return unsigned templates to fit the fork'ssignAndPublishpattern. - Caching: NIP-05 uses a 5-minute TTL with 500-entry LRU. NIP-11 uses a 10-minute TTL with 100-entry LRU. Network errors are not cached (retry on next call).
- Per-sender serialization: Inbound messages from the same pubkey are processed serially to prevent race conditions during relay EOSE bursts (see
PATCHES.md). - Bounded startup catch-up: DM subscriptions first query a capped historical window through startup time, then promote to live subscriptions after EOSE/timeout so relay backlog and live traffic have distinct lifecycles.
- Real outbound IDs: Outbound channel
messageIdvalues are real Nostr identifiers. NIP-04 returns the signed kind:4 event ID; NIP-17 returns the recipient rumor ID after at least one recipient wrap is accepted while the bus also tracks accepted gift-wrap IDs.
HTTP API
All endpoints are under /api/channels/nostr/:accountId/. Authentication is handled by the OpenClaw gateway.
Profile Management
| Method | Endpoint | Description |
| ------ | ------------------------------- | --------------------------- |
| GET | /profile | Get current profile state |
| PUT | /profile | Update and publish profile |
| POST | /profile/import | Import profile from relays |
Identity & Discovery
| Method | Endpoint | Description |
| ------ | ------------------------------- | -------------------------------------- |
| GET | /identity/:nip05 | Resolve NIP-05 address to pubkey |
| GET | /identity/search/:domain?q= | Search NIP-05 domain for users |
| GET | /relay-info?url=wss://... | Get relay NIP-11 capability summary |
Events
| Method | Endpoint | Description |
| ------ | ------------------------------- | --------------------------- |
| POST | /note | Publish a public note |
| POST | /reaction | React to an event |
| DELETE | /events | Delete events |
Example: Resolve a NIP-05 Identity
curl http://localhost:18789/api/channels/nostr/default/identity/alice%40example.com{
"ok": true,
"nip05": "[email protected]",
"pubkey": "aabbccdd...",
"relays": ["wss://relay1.example", "wss://relay2.example"]
}Example: Check Relay Capabilities
curl "http://localhost:18789/api/channels/nostr/default/relay-info?url=wss://relay.sharegap.net"{
"ok": true,
"url": "wss://relay.sharegap.net",
"name": "Damus Relay",
"supportedNips": [1, 4, 9, 11, 12, 16, 20, 22, 28, 33, 40],
"authRequired": false,
"paymentRequired": false,
"restrictedWrites": false
}Docker Deployment
There are four ways to deploy this fork to an existing OpenClaw Docker setup, listed from simplest to most involved.
Option 1: Volume Mount (Recommended)
Mount the fork's source directory into the container and point OpenClaw's plugin loader at it. No image rebuild required.
1. Clone the fork on the Docker host:
git clone https://git.sharegap.net/cascadia/openclaw-nostr.git /opt/openclaw-nostr2. Add to your docker-compose.yml:
services:
openclaw-gateway:
volumes:
- ${OPENCLAW_CONFIG_DIR}:/home/node/.openclaw
- ${OPENCLAW_WORKSPACE_DIR}:/home/node/.openclaw/workspace
# Mount the fork's source
- /opt/openclaw-nostr:/opt/openclaw-nostr:ro3. Tell OpenClaw to load the plugin via openclaw.json:
{
"plugins": {
"load": {
"paths": ["/opt/openclaw-nostr"]
}
},
"channels": {
"nostr": {
"privateKey": "${NOSTR_PRIVATE_KEY}",
"relays": ["wss://relay.sharegap.net", "wss://nos.lol"]
}
}
}4. Restart:
docker compose restart openclaw-gatewayOption 2: Config Extensions Directory
Copy the fork into OpenClaw's user-level extensions directory, which is auto-scanned on startup.
# Copy into the config dir that's already mounted
cp -r /opt/openclaw-nostr "${OPENCLAW_CONFIG_DIR}/extensions/nostr"
# Restart
docker compose restart openclaw-gatewayOpenClaw discovers plugins from ~/.openclaw/extensions/ automatically — no plugins.load.paths config needed.
Option 3: Custom Dockerfile Layer
Build a derived image with the fork baked in. Best for CI/CD pipelines and reproducible deployments.
FROM openclaw:latest
# Copy in the fork
COPY openclaw-nostr /app/extensions/nostr
# The fork overrides the bundled nostr extension at the same pathBuild and run:
docker build -t openclaw-nostr:custom .
OPENCLAW_IMAGE=openclaw-nostr:custom docker compose up -dOption 4: Build-Arg with Full Source
If you're building OpenClaw from source, include the nostr extension via the OPENCLAW_EXTENSIONS build arg:
# From the openclaw source root
docker build \
--build-arg OPENCLAW_EXTENSIONS="nostr" \
-t openclaw:with-nostr .This uses the extensions/nostr directory within the OpenClaw source tree. To use the fork instead, replace extensions/nostr with the fork's source before building.
Plugin Discovery Precedence
OpenClaw discovers plugins in this order (first match wins):
plugins.load.paths— Explicit paths from config (Option 1)- Workspace extensions —
<workspace>/.openclaw/extensions/ - User extensions —
~/.openclaw/extensions/(Option 2) - Bundled extensions —
/app/extensions/inside the image (Options 3 & 4)
The fork at a higher-precedence path will shadow the bundled upstream version.
Docker + Durability Patches
If you also need the runtime durability patches (reconnect fix, subscription handling, etc.), apply them after image build or container start:
# For volume-mount setups, run against the container
docker exec -it openclaw-gateway bash -c '...'
# Or use the apply script against a Docker host
scripts/apply-to-agent.sh user@docker-hostSee PATCHES.md for the full list of runtime patches.
Programmatic Usage
Channel-Level Functions
These are available from channel.ts and operate on named accounts:
import {
// Messaging
publishNostrNote,
publishNostrReaction,
deleteNostrEvents,
// Identity
resolveNostrIdentity,
searchNostrDomain,
validateNostrIdentity,
// Discovery
getNostrRelayInfo,
getNostrRelayCapabilities,
// Auth
getNostrHttpAuthToken,
// Media
publishNostrFileMetadata,
// Social
repostNostrEvent,
// Parsing
parseNostrContent,
} from "./src/channel.js";
// Resolve a NIP-05 identity
const alice = await resolveNostrIdentity("[email protected]");
// { nip05: "[email protected]", pubkey: "aabb...", relays: ["wss://..."] }
// Check relay capabilities
const caps = await getNostrRelayCapabilities("wss://relay.sharegap.net");
// { supportedNips: [1, 4, ...], authRequired: false, ... }
// Parse content into structured blocks
const blocks = parseNostrContent("Hello https://example.com #nostr");
// [{ type: "text", ... }, { type: "url", ... }, { type: "hashtag", ... }]Bus Handle (Direct Access)
For advanced use, get the bus handle from getActiveNostrBuses():
import { getActiveNostrBuses } from "./src/channel.js";
const bus = getActiveNostrBuses().get("default");
// Send a DM and keep the real Nostr ID for logging/threading
const sent = await bus.sendDm(recipientPubkey, "hello from OpenClaw");
// sent.eventId is the kind:4 ID for NIP-04, or the recipient rumor ID for NIP-17
// sent.publishedEventIds contains the signed event IDs accepted by relays
// NIP-44 encrypt a message
const key = bus.getNip44ConversationKey(recipientPubkey);
const encrypted = bus.nip44Encrypt("secret message", key);
// Create a public channel
const channelId = await bus.createChannel({
name: "My Channel",
about: "A public channel for discussion",
});
// Send a channel message
await bus.sendChannelMessage({
channelId,
content: "Hello channel!",
relayUrl: "wss://relay.example",
});
// Generate NIP-98 auth token for a Blossom upload
const token = await bus.getNip98Token("https://media.example.com/upload", "POST");Tier 3 Extras (Namespace Imports)
import { nip49, nip58, nip29, nip47, nip30, BlossomClient } from "./src/nostr-extras.js";
// NIP-49: Encrypt a private key for storage
const ncryptsec = nip49.encrypt(secretKey, "password");
const recovered = nip49.decrypt(ncryptsec, "password");
// NIP-47: Parse a Nostr Wallet Connect string
const connection = nip47.parseConnectionString("nostr+walletconnect://...");
// NIP-30: Find custom emoji in content
for (const match of nip30.matchAll(":custom_emoji: hello")) {
console.log(match.shortcode, match.url);
}Testing
Local Relay (Recommended)
# Using strfry
docker run -p 7777:7777 ghcr.io/hoytech/strfry
# Configure openclaw to use local relay
"relays": ["ws://localhost:7777"]Running Tests
Tests run via vitest from the parent OpenClaw workspace:
# From the openclaw workspace root
pnpm vitest run --config vitest.extensions.config.ts extensions/nostr/
# Run a specific test file
pnpm vitest run --config vitest.extensions.config.ts extensions/nostr/src/nostr-discovery.test.tsTest Coverage
| Test File | Covers |
| -------------------------------------- | --------------------------------------------------------- |
| nostr-bus.protocol.test.ts | NIP-04/NIP-17 DM pipeline, reply routing, serialization |
| nostr-capabilities-extended.test.ts | NIP-18 reposts, NIP-28 channels, NIP-13/44/57/94/42/98 |
| nostr-discovery.test.ts | NIP-05 identity, NIP-11 relay info, NIP-27 content parsing|
| nostr-extras.test.ts | Tier 3 re-export surface verification |
| nostr-profile-http.test.ts | HTTP API endpoints including identity/relay-info routes |
| nip46-signer.test.ts | NIP-46 client secret encode/decode, bunker URL parsing, request queue |
| nip46-doctor.test.ts | NIP-46 preflight diagnostics (config validation, report formatting) |
| nip46-enroll.test.ts | NIP-46 enrollment flow (secret gen, URL parsing, progress) |
| nip46-cutover.test.ts | NIP-46 cutover migration (preconditions, rollback) |
| nip46-status.test.ts | Custody status resolution and formatting |
| config-schema.test.ts | Config validation including NIP-46 fields |
| types.test.ts | Account resolution including NIP-46 config, env/file/exec secret sources |
Manual Test
- Start the gateway with Nostr configured
- Open Damus, Amethyst, or another Nostr client
- Send a DM to your bot's npub
- Verify the bot responds
Security Notes
- Private keys are never logged
- Event signatures are verified before processing
- Use environment variables for keys, never commit to config files
- Consider using
allowlistmode in production - NIP-98 tokens are signed with the bus's key — scope them to specific URLs
- NIP-44 conversation keys are derived from the bus's secret key
- HTTP mutation endpoints (PUT, POST, DELETE) are restricted to loopback addresses
- NIP-46: The client secret (
nip46Secret) is distinct from the identity key — store it viaNOSTR_NIP46_SECRETenv var or aSecretRef(env,file,exec), never in raw config - NIP-46 cutover: Use
performCutover()for safe migration — it auto-rolls back if verification fails
Troubleshooting
Bot not receiving messages
- Verify private key (or NIP-46 bunker URL + secret) is correctly configured
- Check relay connectivity
- Ensure
enabledis not set tofalse - Check the bot's public key matches what you're sending to
NIP-46 connection failing
- Verify the bunker is online and reachable
- Check
nip46BunkerUrlis a validbunker://URL oruser@domain - Verify
NOSTR_NIP46_SECRETenv var is set (64-char hex) - Check relay connectivity to the signer relays
- Increase
nip46ConnectionTimeoutMsif the bunker is slow to respond - Check logs for
NIP-46 auth URL— the bunker may require user approval
Messages not being delivered
- Check relay URLs are correct (must use
wss://) - Verify relays are online and accepting connections
- Check for rate limiting (reduce message frequency)
Docker: Plugin not loading
- Verify the volume mount path is correct and readable
- Check
plugins.load.pathspoints to the right directory - Run
openclaw plugins listto see discovered plugins - Check container logs:
docker compose logs openclaw-gateway
NIP-05 resolution failing
- The target domain must serve
/.well-known/nostr.json - Check for CORS issues if resolving from a browser context
- Results are cached for 5 minutes — wait or restart to retry
License
MIT
