Skip to content

Latest commit

 

History

62 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

nmilat

CI Go Reference Go Report Card Go Version License: Unlicense Go

nmilat is a Go SDK for building on the Nostr protocol. It handles the plumbing — event parsing, signing, verification, and 32 NIPs — so you can focus on what you're building.

Use it to:

  • Build a client or bot that reads, signs, and publishes Nostr events
  • Run your own relay with the embeddable engine: event storage, sessions, and profile search included
  • Talk to remote relays over WebSocket without hand-rolling the wire protocol

No CLI or UI code lives here — this module is a library only. The ncli application is built on top of it.

Install

go get github.com/ohstr/nmilat

Package overview

Implemented NIPs

  • nip01 — Core event, filter, and subscription types (the foundation every other package builds on)
  • nip04, nip44, nip49 — Encryption: direct messages, payloads, private keys
  • nip05 — NIP-05 identity verification
  • nip09 — Event deletion
  • nip11 — Relay information document
  • nip13 — Proof of work
  • nip16 — Event treatment (regular/replaceable/ephemeral kinds; folded into NIP-01 upstream)
  • nip17, nip59 — Private direct messages, gift wraps
  • nip19 — Bech32-encoded entities: npub, nsec, note, plus the TLV-based nprofile, nevent, and naddr
  • nip23 — Long-form content
  • nip26 — Event delegation
  • nip33 — Parameterized replaceable events (renamed "addressable events" and folded into NIP-01 upstream)
  • nip40 — Event expiration
  • nip42, nip98 — Relay/HTTP authentication
  • nip43 — Relay access metadata and requests
  • nip46 — Nostr Connect (remote signing)
  • nip47 — Wallet Connect (NWC): info/request/response/notification events, encryption negotiation, pairing URI
  • nip48 — Proxy tags
  • nip57 — Lightning zaps
  • nip65 — Relay list metadata
  • nip77 — Negentropy sync
  • nip88 — Polls
  • nip90 — Data Vending Machines
  • nipAA — Agent Auth
  • nipAZ — AltZap: zaps for energy-backed coins
  • nipB0 — Web bookmarks
  • nipB7 — Blossom media
  • nipcash — Cash Hub: a Chaumian ecash system built directly on NIP-47
  • nipcw — Circle Wallet: self-service NIP-47 wallets for a group sharing one host's node
  • nipIC — Identity Connection: binds Web Identity accounts to Nostr pubkeys
  • nipOA — Owner Attestation

Relay engine and infrastructure

  • relay — Embeddable relay engine
  • relay/client — Relay client (WebSocket, NWC)
  • relay/migrations — Event store schema migrations
  • search — Profile search indexing/ranking
  • config — Embedded YAML config for search
  • wire — Relay wire-protocol packet types
  • utils — Shared event/key/logging helpers

NIP packages with relay-side concerns (NIP-47/48/57/65/88/90/B0/B7) stay dependency-free on their own; blank-import their relayreg subpackage to declare relay support, e.g. import _ "github.com/ohstr/nmilat/nip57/relayreg". See "Run a relay" below.

Quick start

Run a relay

package main

import (
	"log"
	"net/http"

	"github.com/ohstr/nmilat/nip11"
	"github.com/ohstr/nmilat/relay"

	// Blank-import the relayreg subpackage for every optional NIP this relay
	// should declare support for and auto-validate incoming events against.
	// Without these, relay.New still works — it just won't know about
	// zaps/polls/DVMs/etc. NIP-09/16/33/40/77 are always on (core to NIP-01
	// handling), and NIP-42/43/AA/26/50 turn on automatically from
	// SessionConfig — none of those need a relayreg import.
	_ "github.com/ohstr/nmilat/nip57/relayreg"
	_ "github.com/ohstr/nmilat/nip65/relayreg"
)

func main() {
	metadata := &nip11.Metadata{
		Name:       "my-relay",
		Limitation: nip11.Limitation{MaxLimit: 1000, MaxMessageLength: 1024 * 1024},
	}

	rl, err := relay.New("relay.db", metadata)
	if err != nil {
		log.Fatal(err)
	}
	defer rl.Close()

	log.Fatal(http.ListenAndServe(":8080", rl))
}

relay.New includes NIP-11 relay-info negotiation and starts profile verification, with search disabled. For storage tuning, a search service, or session options (CORS allowlist, NIP-26 delegation, ...), build the store and handler directly with relay.NewEventStore/relay.NewSessionHandler instead.

Connect with relayclient.Connect against ws://localhost:8080 (next example).

Read events from a relay

Connect, subscribe to a filter, and read events until EOSE. Relay input is untrusted, so always call Verify() before acting on an event:

package main

import (
	"context"
	"fmt"
	"net/url"

	"github.com/ohstr/nmilat/nip01"
	relayclient "github.com/ohstr/nmilat/relay/client"
)

func main() {
	filters := nip01.NewSubscriptionFilterGroup(nip01.NewFilter().WithKinds(1).WithLimit(10))

	relayURL, _ := url.Parse("wss://relay.ohstr.com")
	events, err := relayclient.ReadEventsFromRelay(context.Background(), relayURL, filters)
	if err != nil {
		panic(err)
	}
	for _, ev := range events {
		if err := ev.Verify(); err != nil {
			continue // bad signature, bad ID, or malformed — skip it
		}
		fmt.Println(ev.ID, ev.Content)
	}
}

Build, sign, and publish an event

Create an event, sign it with your private key, and publish it to a relay:

package main

import (
	"context"
	"fmt"
	"net/url"

	"github.com/ohstr/nmilat/nip01"
	relayclient "github.com/ohstr/nmilat/relay/client"
)

func main() {
	// privateKeyHex is your hex-encoded Nostr private key — see
	// "Encode & decode keys" below for converting to/from npub/nsec.
	ev, err := nip01.NewSignedEvent(1, "hello nostr", privateKeyHex)
	if err != nil {
		panic(err)
	}

	relayURL, _ := url.Parse("wss://relay.ohstr.com")
	conn, err := relayclient.Connect(context.Background(), relayURL)
	if err != nil {
		panic(err)
	}
	defer conn.Close()

	res, err := conn.Publish(context.Background(), ev)
	if err != nil {
		panic(err)
	}
	fmt.Println("accepted:", res.Accepted, res.Message)
}

Send a private direct message (NIP-17/59)

Build a chat message, seal and gift-wrap it so only the recipient can read it (sender identity included), and publish the wrapper like any other event:

package main

import (
	"context"
	"fmt"
	"net/url"

	"github.com/ohstr/nmilat/nip17"
	"github.com/ohstr/nmilat/nip59"
	relayclient "github.com/ohstr/nmilat/relay/client"
)

func main() {
	rumor := nip17.NewChatMessage("gm from nmilat")
	if err := rumor.Sign(senderPrivKeyHex); err != nil {
		panic(err)
	}

	// Wrap encrypts the rumor twice (seal, then gift wrap) so relays and
	// onlookers see only an anonymous kind-1059 event addressed to recipientPubKeyHex.
	giftWrap, err := nip59.Wrap(rumor, senderPrivKeyHex, recipientPubKeyHex)
	if err != nil {
		panic(err)
	}

	relayURL, _ := url.Parse("wss://relay.ohstr.com")
	conn, err := relayclient.Connect(context.Background(), relayURL)
	if err != nil {
		panic(err)
	}
	defer conn.Close()

	res, err := conn.Publish(context.Background(), giftWrap)
	if err != nil {
		panic(err)
	}
	fmt.Println("delivered:", res.Accepted, res.Message)
}

Send a zap request (NIP-57)

nip57 implements the spec-compliant kind 9734/9735 zap request/receipt/LNURL flow:

package main

import (
	"context"
	"fmt"
	"net/url"

	"github.com/ohstr/nmilat/nip57"
	relayclient "github.com/ohstr/nmilat/relay/client"
)

func main() {
	zapRequest := nip57.NewZapRequest(nip57.ZapRequestParams{
		Recipient:  recipientPubKeyHex,
		Lnurl:      recipientLnurl,
		AmountMsat: 21000,
		Relays:     []string{"wss://relay.ohstr.com"},
	})
	if err := zapRequest.Sign(senderPrivKeyHex); err != nil {
		panic(err)
	}

	relayURL, _ := url.Parse("wss://relay.ohstr.com")
	conn, err := relayclient.Connect(context.Background(), relayURL)
	if err != nil {
		panic(err)
	}
	defer conn.Close()

	res, err := conn.Publish(context.Background(), zapRequest)
	if err != nil {
		panic(err)
	}
	fmt.Println("zap request accepted:", res.Accepted, res.Message)
}

Send an AltZap request to a Web Identity recipient (NIP-AZ + NIP-IC)

AltZap is the same flow for non-Bitcoin chains — a mandatory chain tag and its own kinds (5520-5523). Its most common use isn't zapping a native Nostr pubkey (nipAZ.Pubkey(hex)) — it's zapping a recipient who only has an account on another platform and no Nostr keypair yet. nipAZ.Connection covers that case by deriving a NIP-IC ConnectionKey internally:

package main

import (
	"context"
	"fmt"
	"net/url"

	"github.com/ohstr/nmilat/nipAZ"
	relayclient "github.com/ohstr/nmilat/relay/client"
)

func main() {
	zapRequest, err := nipAZ.NewAltZapRequest(nipAZ.AltZapRequestParams{
		PrivateKey: senderPrivKeyHex, // signs internally
		Chain:      "flokicoin",      // prevents cross-chain replay
		// Recipient has no Nostr pubkey yet — identified by their Discord
		// account instead. nipAZ.Connection hashes platform+externalID into
		// a nipIC.ConnectionKey; use nipAZ.Pubkey(hex) for a native Nostr
		// recipient instead.
		Recipient:   nipAZ.Connection("discord", externalUserID),
		Lnurl:       recipientLnurl,
		AmountMloki: 21000,
		Relays:      []string{"wss://relay.ohstr.com"},
	})
	if err != nil {
		panic(err)
	}

	relayURL, _ := url.Parse("wss://relay.ohstr.com")
	conn, err := relayclient.Connect(context.Background(), relayURL)
	if err != nil {
		panic(err)
	}
	defer conn.Close()

	res, err := conn.Publish(context.Background(), zapRequest)
	if err != nil {
		panic(err)
	}
	fmt.Println("zap request accepted:", res.Accepted, res.Message)
}

Bind a Web Identity to a Nostr pubkey (NIP-IC)

nipIC implements Identity Connection: an Identity Authority (IA) attests that a Web Identity account (Discord, Telegram, ...) belongs to a Nostr pubkey by signing a Kind 35522 event; the user then references it from their own Kind 35521.

package main

import (
	"fmt"

	"github.com/ohstr/nmilat/nipIC"
)

func main() {
	// 1. Mint a challenge + pre-auth code for the user to prove control of
	//    their Nostr key, e.g. by posting the pre-auth code publicly.
	challenge, preAuthCode, err := nipIC.NewChallenge(userPubkeyHex)
	if err != nil {
		panic(err)
	}

	// 2. Once the IA has verified the public post, it signs the attestation.
	connectionKey := nipIC.NewConnectionKey("discord", externalUserID)
	attestation, err := nipIC.NewAttestation(nipIC.AttestationParams{
		PrivateKey:     iaPrivKeyHex, // IA's nsec hex, signs internally
		ConnectionKey:  connectionKey,
		UserPubkey:     userPubkeyHex,
		Platform:       "discord",
		ExpirationDays: 90,
		Evidence: nipIC.Evidence{
			Platform:    "discord",
			UserID:      externalUserID,
			Username:    "alice",
			EvidenceURL: "https://discord.com/channels/.../123456789",
			Challenge:   challenge,
			PreAuthCode: preAuthCode,
		},
	})
	if err != nil {
		panic(err)
	}
	fmt.Println("attestation id:", attestation.ID)
}

A verifier re-checks cross-IA re-attestation evidence with challenge.Verify(userPubkeyHex, preAuthCode) before trusting it — see NIP-IC's Cross-IA Challenge Binding for the full security model.

Pay an invoice over Nostr Wallet Connect (NIP-47)

Parse a nostr+walletconnect:// pairing URI and construct a NWCClient — it dials the wallet's relay once and keeps the connection open for reuse across calls. PayInvoice and the other wallet operations are plain methods: no type parameter at the call site, and a wallet-side decline comes back as a *relayclient.WalletError you can errors.As for the code and message:

package main

import (
	"context"
	"fmt"

	"github.com/ohstr/nmilat/nip47"
	relayclient "github.com/ohstr/nmilat/relay/client"
)

func main() {
	// pairingURI is the nostr+walletconnect:// string the user's wallet gave
	// you; it carries the wallet's pubkey, relay(s), and your app's secret key.
	pairing, err := nip47.ParsePairingURI(pairingURI)
	if err != nil {
		panic(err)
	}

	wallet, err := relayclient.NewNWCClient(context.Background(), pairing, nip47.EncryptionNIP44V2)
	if err != nil {
		panic(err)
	}
	defer wallet.Close()

	result, err := wallet.PayInvoice(context.Background(), nip47.PayInvoiceParams{
		Invoice: "lnfcxxxx....", // lnbcxxx for bitcoin
	})
	if err != nil {
		panic(err)
	}
	fmt.Println("paid! preimage:", result.Preimage)
}

Mint cash and redeem it into a Circle Wallet (NIP-CASH + NIP-CW)

NIP-CASH mints lokicash1.../satscash1... bech32 tokens that carry real, spendable value the moment they're minted — hand one to someone the way you'd hand over a bill. NIP-CW self-serves a personal wallet from a host's own node, for people who don't want to run one themselves. The two compose naturally: a Circle Wallet member redeeming a cash token straight into their own wallet, on the same host that minted the cash, is the one case where cash_redeem's same-node fee exemption applies deterministically — always the full amount, zero fee, since nothing leaves the node. Both packages follow the protocol/client split every other NIP here does (nipcash builds/parses, nipcash/client dials out) — imported under an alias here since both are used together in the same file:

package main

import (
	"context"
	"fmt"
	"time"

	"github.com/ohstr/nmilat/nip47"
	"github.com/ohstr/nmilat/nipcash"
	cashclient "github.com/ohstr/nmilat/nipcash/client"
	"github.com/ohstr/nmilat/nipcw"
	cwclient "github.com/ohstr/nmilat/nipcw/client"
	relayclient "github.com/ohstr/nmilat/relay/client"
)

func main() {
	ctx := context.Background()

	// Mint a slice for a Nostr-identified recipient, over the Cash Hub's
	// own connection.
	hub, err := cashclient.Connect(ctx, cashHubPairingURI)
	if err != nil {
		panic(err)
	}
	defer hub.Close()

	minted, err := hub.MintCash(ctx, nipcash.MintCashParams{
		Recipients: []nipcash.Allocation{nipcash.Send(nipcash.Pubkey(memberPubkeyHex), 21_000_000)},
		Expiry:     24 * time.Hour,
	})
	if err != nil {
		panic(err)
	}

	// The member self-serves their own Circle Wallet from the host's Hub.
	circleHub, err := cwclient.Connect(ctx, circleHubPairingURI)
	if err != nil {
		panic(err)
	}
	defer circleHub.Close()

	wallet, err := circleHub.CreateCircleWallet(ctx, nipcw.CreateCircleWalletParams{
		Credential:      nipcw.BySigning(memberPrivKeyHex),
		MaxAmountMillis: 100_000_000,
	})
	if err != nil {
		panic(err)
	}

	// Invoice from their own Circle Wallet — same host as the Cash Hub, so
	// this redemption always resolves same-node: full amount, zero fee.
	memberPairing, err := nip47.ParsePairingURI(wallet.PairingURI)
	if err != nil {
		panic(err)
	}
	member, err := relayclient.NewNWCClient(ctx, memberPairing, nip47.EncryptionNIP44V2)
	if err != nil {
		panic(err)
	}
	defer member.Close()

	invoice, err := member.MakeInvoice(ctx, nip47.MakeInvoiceParams{Amount: 21_000_000})
	if err != nil {
		panic(err)
	}

	cashWallet, err := cashclient.Connect(ctx, minted.CashToken)
	if err != nil {
		panic(err)
	}
	defer cashWallet.Close()

	result, err := cashWallet.CashRedeem(ctx, nipcash.CashRedeemParams{
		Invoice:    invoice.Invoice,
		Credential: nipcash.BySigning(memberPrivKeyHex),
	})
	if err != nil {
		panic(err)
	}
	fmt.Println("redeemed! preimage:", result.Preimage, "fees paid:", result.FeesPaid)
}

Upload a blob to a Blossom server (NIP-B7)

Build a BUD-11 Authorization token scoped to the upload verb, then hand it to nipB7/client to stream the blob to a server and get back its Blob Descriptor:

package main

import (
	"context"
	"fmt"
	"strings"
	"time"

	"github.com/ohstr/nmilat/nipB7"
	blossom "github.com/ohstr/nmilat/nipB7/client"
)

func main() {
	auth := nipB7.NewAuthorization(nipB7.AuthorizationParams{
		Verb:       nipB7.VerbUpload,
		Content:    "Upload blob",
		Expiration: time.Now().Add(5 * time.Minute),
	})
	if err := auth.Sign(privateKeyHex); err != nil {
		panic(err)
	}

	c := &blossom.Client{}
	descriptor, err := c.Upload(context.Background(), "https://blossom.example", blossom.UploadRequest{
		Body:        strings.NewReader("hello nostr"),
		Size:        11,
		ContentType: "text/plain",
		Auth:        auth,
	})
	if err != nil {
		panic(err)
	}
	fmt.Println("stored at:", descriptor.URL)
}

c.Get/c.GetFromServers download the same way (streamed, with server-list fallback), and nipB7.VerifyAuthorization is the server-side, BUD-11 analogue of NIP-98's VerifyAuthHeader.

Encode & decode entities (NIP-19)

Convert between raw hex keys/IDs and Nostr's bech32 encoding (npub/nsec/note):

import "github.com/ohstr/nmilat/nip19"

npub, err := nip19.EncodePublicKey(pubkeyHex)
if err != nil {
	panic(err)
}
fmt.Println(npub) // npub1...

decoded, err := nip19.DecodePublicKey(npub)
if err != nil {
	panic(err)
}
fmt.Println(decoded) // pubkeyHex

DecodePublicKey/DecodePrivateKey/DecodeNote are typed wrappers; the generic nip19.Decode is also available for callers that need to handle an identifier of unknown/mixed type.

The TLV-based "shareable identifiers with extra metadata" — nprofile, nevent, and naddr — carry a public key/event ID plus optional relay hints (and, for nevent/naddr, an optional author and kind):

nprofile, err := nip19.EncodeProfile(pubkeyHex, []string{"wss://relay.example.com"})
profile, err := nip19.DecodeProfile(nprofile) // *nip19.ProfilePointer{PublicKey, Relays}

nevent, err := nip19.EncodeEvent(nip19.EventPointer{
	ID:     eventIDHex,
	Relays: []string{"wss://relay.example.com"},
	Author: pubkeyHex, // optional
	Kind:   1,         // optional
})
event, err := nip19.DecodeEvent(nevent) // *nip19.EventPointer

naddr, err := nip19.EncodeAddr(nip19.EntityPointer{
	Identifier: "my-article",
	PublicKey:  pubkeyHex,
	Kind:       30023,
	Relays:     []string{"wss://relay.example.com"},
})
addr, err := nip19.DecodeAddr(naddr) // *nip19.EntityPointer

Development

Uses just for build automation:

just build   # compile-check (library, no binary)
just test    # go test ./...
just vet     # go vet ./...
just tidy    # go mod tidy
just check   # build + vet + test

License

Unlicense — public domain.

About

A Go library for building Nostr clients, bots, cli, and relays

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages