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.
go get github.com/ohstr/nmilatnip01— Core event, filter, and subscription types (the foundation every other package builds on)nip04,nip44,nip49— Encryption: direct messages, payloads, private keysnip05— NIP-05 identity verificationnip09— Event deletionnip11— Relay information documentnip13— Proof of worknip16— Event treatment (regular/replaceable/ephemeral kinds)nip17,nip59— Private direct messages, gift wrapsnip19— Bech32-encoded entities: npub, nsec, note, plus the TLV-based nprofile, nevent, and naddrnip22— Comment: generic kind:1111 threading notenip23— Long-form contentnip26— Event delegationnip33— Parameterized replaceable events (now called addressable events)nip34— git stuff: code collaboration over Nostrnip40— Event expirationnip42,nip98— Relay/HTTP authenticationnip43— Relay access metadata and requestsnip46— Nostr Connect (remote signing)nip47— Wallet Connect (NWC): info/request/response/notification events, encryption negotiation, pairing URInip48— Proxy tagsnip57— Lightning zapsnip65— Relay list metadatanip77— Negentropy syncnip88— Pollsnip90— Data Vending MachinesnipAA— Agent AuthnipAZ— AltZap: zaps for energy-backed coinsnipB0— Web bookmarksnipB7— Blossom medianipcash— Cash Hub: ecash systemnipcw— Circle Wallet: shared self-service walletsnipIC— Identity Connection: binds Web Identity accounts to Nostr pubkeysnipOA— Owner Attestation
relay— Embeddable relay enginerelay/client— Relay client (WebSocket, NWC)relay/migrations— Event store schema migrationssearch— Profile search indexing/rankingconfig— Embedded YAML config for searchwire— Relay wire-protocol packet typesutils— Shared event/key/logging helpers
NIP packages with relay-side concerns (NIP-22/34/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.
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).
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)
}
}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)
}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)
}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)
}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)
}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.
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)
}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)
}A bearer-mode slice (nipcash.Anyone() as the recipient) has no
Nostr identity attached — whoever holds its bearer_secret can spend it.
CheckClaim confirms a received slice is real before trusting it;
RekeyBearerSlice then re-keys it under a fresh secret only the new
holder knows, so the old one stops working:
package main
import (
"context"
"errors"
"fmt"
"time"
"github.com/ohstr/nmilat/nipcash"
cashclient "github.com/ohstr/nmilat/nipcash/client"
)
func main() {
ctx := context.Background()
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.Anyone(), 21_000_000)},
Expiry: 24 * time.Hour,
})
if err != nil {
panic(err)
}
// One string to hand over: the combined "<token>#<bearer_secret>"
// presentation.
billString := minted.CashToken + "#" + minted.Recipients[0].BearerSecret
// Recipient's side: split it, then verify it's real.
token, secret := nipcash.SplitBearerSliceString(billString)
tok, err := nipcash.Decode(token)
if err != nil {
panic(err)
}
recipient, err := cashclient.Connect(ctx, token)
if err != nil {
panic(err)
}
defer recipient.Close()
check, err := recipient.CheckClaim(ctx, tok, nipcash.NoLocalIdentity)
if errors.Is(err, nipcash.ErrClaimNotFound) {
panic("dead, already-claimed, or never a real slice")
} else if err != nil {
panic(err)
}
fmt.Println("verified:", check.AmountMillis, "millis, bearer:", check.IsBearer)
// Re-key it: secured.NewSecret is the only copy, persist it now.
secured, err := recipient.RekeyBearerSlice(ctx, cashclient.RekeyBearerSliceParams{
BearerSlice: nipcash.Source{
WalletPubkey: tok.WalletPubkey,
Amount: check.AmountMillis,
Credential: nipcash.BySecret(secret),
},
})
if err != nil {
panic(err)
}
fmt.Println("secured — new secret only this wallet knows:", secured.NewSecret)
}To merge with another same-issuer slice you already hold, add
ConsolidateWith, InterimIdentity, and InterimCredential to the same
call. nipcash/client.TransferFromSources does the reverse: send a
specific amount, drawing from and auto-consolidating several sources.
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.
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) // pubkeyHexDecodePublicKey/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.EntityPointernip34 covers the whole spec -- repository announcements/state, patches,
pull requests, issues, threaded replies (via nip22), status, grasp
lists, and nostr:// clone URLs. Every event type follows the same
New*/Parse*/Validate* shape; the examples below walk through each
one, in the order a repository's activity actually happens.
package main
import (
"fmt"
"github.com/ohstr/nmilat/nip34"
"github.com/ohstr/nmilat/utils"
)
func main() {
announceEv, err := nip34.NewRepositoryAnnouncement(nip34.RepositoryAnnouncementParams{
Pubkey: ownerPubkeyHex,
Identifier: "ngit",
Name: "ngit",
Description: "git over nostr",
Web: []string{"https://gitworkshop.dev/ngit"},
Clone: []string{"https://github.com/example/ngit.git"},
Relays: []string{"wss://relay.ngit.dev"},
EarliestUniqueCommit: rootCommitHex,
Maintainers: []string{maintainerPubkeyHex},
Hashtags: []string{"git", "nostr"},
})
if err != nil {
panic(err)
}
if err := announceEv.Sign(ownerPrivateKeyHex); err != nil {
panic(err)
}
stateEv, err := nip34.NewRepositoryState(nip34.RepositoryStateParams{
Pubkey: ownerPubkeyHex,
Identifier: "ngit",
Refs: []nip34.Ref{
{Name: "refs/heads/master", CommitID: tipCommitHex},
{Name: "refs/tags/v1.0.0", CommitID: tagCommitHex},
},
Head: "refs/heads/master",
})
if err != nil {
panic(err)
}
if err := stateEv.Sign(ownerPrivateKeyHex); err != nil {
panic(err)
}
// What a subscriber does on receipt: verify, then parse.
if err := nip34.ValidateRepositoryAnnouncement(announceEv); err != nil {
panic(err)
}
repo, err := nip34.ParseRepositoryAnnouncement(announceEv)
if err != nil {
panic(err)
}
fmt.Println(repo.Name, repo.Clone, repo.Maintainers)
// The repo's "a" tag address, for filtering patches/issues/PRs sent to it.
repoAddr, err := utils.FormatATag(nip34.KindRepositoryAnnouncement, ownerPubkeyHex, repo.Identifier)
if err != nil {
panic(err)
}
fmt.Println("repo address:", repoAddr)
}ValidateRepositoryState/ParseRepositoryState are the state event's own
counterparts, shown together with New above.
package main
import (
"fmt"
"github.com/ohstr/nmilat/nip34"
)
func main() {
repoAddr := "30617:" + ownerPubkeyHex + ":ngit"
// Root patch: first in the series.
rootPatch, err := nip34.NewPatch(nip34.PatchParams{
Pubkey: contributorPubkeyHex,
Content: "diff --git a/main.go b/main.go\n...", // `git format-patch` output
RepoAddress: repoAddr,
RepositoryOwner: ownerPubkeyHex,
IsRoot: true,
Commit: newCommitHex,
ParentCommit: parentCommitHex,
Committer: &nip34.Committer{
Name: "Ada Contributor", Email: "ada@example.com",
Timestamp: 1_700_000_000, TZOffsetMinutes: -60,
},
})
if err != nil {
panic(err)
}
if err := rootPatch.Sign(contributorPrivateKeyHex); err != nil {
panic(err)
}
// Second patch in the same series: replies to the root patch.
secondPatch, err := nip34.NewPatch(nip34.PatchParams{
Pubkey: contributorPubkeyHex,
Content: "diff --git a/util.go b/util.go\n...",
RepoAddress: repoAddr,
RepositoryOwner: ownerPubkeyHex,
ReplyTo: rootPatch.ID,
Commit: secondCommitHex,
ParentCommit: newCommitHex,
})
if err != nil {
panic(err)
}
if err := secondPatch.Sign(contributorPrivateKeyHex); err != nil {
panic(err)
}
// A revised series (e.g. after review feedback): its first patch is
// tagged root-revision and replies to the original root patch.
revisionPatch, err := nip34.NewPatch(nip34.PatchParams{
Pubkey: contributorPubkeyHex,
Content: "diff --git a/main.go b/main.go\n... (v2)",
RepoAddress: repoAddr,
RepositoryOwner: ownerPubkeyHex,
IsRootRevision: true,
ReplyTo: rootPatch.ID,
})
if err != nil {
panic(err)
}
if err := revisionPatch.Sign(contributorPrivateKeyHex); err != nil {
panic(err)
}
// For a change too large for a patch (spec: SHOULD use a PR over 60kb),
// a pull request points at a branch on a regular git host instead.
prEv, err := nip34.NewPullRequest(nip34.PullRequestParams{
Pubkey: contributorPubkeyHex,
Content: "Adds the cool feature described in issue #12.",
RepoAddress: repoAddr,
RepositoryOwner: ownerPubkeyHex,
Subject: "Add cool feature",
Labels: []string{"enhancement"},
Commit: tipCommitHex,
CloneURLs: []string{"https://github.com/contributor/ngit.git"},
BranchName: "cool-feature",
})
if err != nil {
panic(err)
}
if err := prEv.Sign(contributorPrivateKeyHex); err != nil {
panic(err)
}
// Pushed more commits to the same branch: update the PR's tip.
prUpdateEv, err := nip34.NewPullRequestUpdate(nip34.PullRequestUpdateParams{
Pubkey: contributorPubkeyHex,
RepoAddress: repoAddr,
PullRequestEventID: prEv.ID,
PullRequestAuthor: contributorPubkeyHex,
Commit: newerTipCommitHex,
CloneURLs: []string{"https://github.com/contributor/ngit.git"},
})
if err != nil {
panic(err)
}
if err := prUpdateEv.Sign(contributorPrivateKeyHex); err != nil {
panic(err)
}
// Parsing a patch back out, e.g. after fetching it from a relay.
parsed, err := nip34.ParsePatch(rootPatch)
if err != nil {
panic(err)
}
fmt.Println(parsed.IsRoot, parsed.Commit, parsed.Committer.Name)
}ParsePullRequest/ValidatePullRequest and
ParsePullRequestUpdate/ValidatePullRequestUpdate mirror ParsePatch
above for the PR events.
Replies to an issue, patch, or PR follow NIP-22's kind:1111 comment
shape; nip34.NewReply/nip34.ParseReply are a thin convenience layer
over the nip22 package that also checks the thread actually roots at a
NIP-34 item:
package main
import (
"fmt"
"github.com/ohstr/nmilat/nip34"
)
func main() {
repoAddr := "30617:" + ownerPubkeyHex + ":ngit"
issueEv, err := nip34.NewIssue(nip34.IssueParams{
Pubkey: contributorPubkeyHex,
Content: "The build is broken on main.",
RepoAddress: repoAddr,
RepositoryOwner: ownerPubkeyHex,
Subject: "Build broken",
Labels: []string{"bug"},
})
if err != nil {
panic(err)
}
if err := issueEv.Sign(contributorPrivateKeyHex); err != nil {
panic(err)
}
// Top-level reply: RootEvent and ParentEvent are the same (issue).
maintainerReply, err := nip34.NewReply(nip34.ReplyParams{
Pubkey: ownerPubkeyHex,
Content: "thanks for reporting, looking into it",
RootEvent: issueEv,
})
if err != nil {
panic(err)
}
if err := maintainerReply.Sign(ownerPrivateKeyHex); err != nil {
panic(err)
}
// Nested reply: RootEvent stays the issue, ParentEvent is the previous
// reply -- this is how a threaded discussion is built up.
followUp, err := nip34.NewReply(nip34.ReplyParams{
Pubkey: contributorPubkeyHex,
Content: "any update?",
RootEvent: issueEv,
ParentEvent: maintainerReply,
})
if err != nil {
panic(err)
}
if err := followUp.Sign(contributorPrivateKeyHex); err != nil {
panic(err)
}
// Parsing a reply back out (e.g. after fetching from a relay):
// nip34.ParseReply is nip22.ParseComment plus a check that the thread's
// root is actually an issue/patch/PR.
comment, err := nip34.ParseReply(followUp)
if err != nil {
panic(err)
}
fmt.Println("replying to root", comment.Root.Pointer.Value, "kind", comment.Root.Kind)
fmt.Println("direct parent", comment.Parent.Pointer.Value)
}package main
import (
"fmt"
"github.com/ohstr/nmilat/nip01"
"github.com/ohstr/nmilat/nip34"
)
func main() {
// Open is the implicit default; an explicit Open event re-opens a
// previously closed/applied thread.
openEv, err := nip34.NewStatus(nip34.StatusParams{
Pubkey: contributorPubkeyHex,
Kind: nip34.KindStatusOpen,
RootID: patchEventID,
RepositoryOwner: ownerPubkeyHex,
RootAuthor: contributorPubkeyHex,
})
if err != nil {
panic(err)
}
if err := openEv.Sign(contributorPrivateKeyHex); err != nil {
panic(err)
}
// Applied/Merged: a maintainer merges a patch revision, citing it and
// the resulting merge commit.
appliedEv, err := nip34.NewStatus(nip34.StatusParams{
Pubkey: ownerPubkeyHex,
Kind: nip34.KindStatusApplied,
RootID: patchEventID,
AcceptedRevisionID: revisionEventID,
RepositoryOwner: ownerPubkeyHex,
RootAuthor: contributorPubkeyHex,
RevisionAuthor: contributorPubkeyHex,
AppliedPatches: []nip34.QuotedPatch{{EventID: revisionEventID, Pubkey: contributorPubkeyHex}},
MergeCommit: mergeCommitHex,
Content: "merged, thanks!",
})
if err != nil {
panic(err)
}
if err := appliedEv.Sign(ownerPrivateKeyHex); err != nil {
panic(err)
}
// Closed: rejected without merging.
closedEv, err := nip34.NewStatus(nip34.StatusParams{
Pubkey: ownerPubkeyHex,
Kind: nip34.KindStatusClosed,
RootID: patchEventID,
RepositoryOwner: ownerPubkeyHex,
Content: "superseded by a different approach",
})
if err != nil {
panic(err)
}
if err := closedEv.Sign(ownerPrivateKeyHex); err != nil {
panic(err)
}
// Draft: not ready for review yet (set by the author).
draftEv, err := nip34.NewStatus(nip34.StatusParams{
Pubkey: contributorPubkeyHex,
Kind: nip34.KindStatusDraft,
RootID: patchEventID,
})
if err != nil {
panic(err)
}
if err := draftEv.Sign(contributorPrivateKeyHex); err != nil {
panic(err)
}
fmt.Println("closed kind:", closedEv.Kind, "draft kind:", draftEv.Kind)
// Resolving which status actually counts: a client subscribes to every
// 1630-1633 event for a thread (see "Subscribe to a repository's
// activity" below), parses each, and picks the winner per the spec --
// latest by created_at, from the root author or a recognized
// maintainer. A status from anyone else is ignored even if it's newer:
fetched := []*nip34.Status{
{Event: &nip01.Event{PubKey: contributorPubkeyHex, CreatedAt: 1_700_000_000}, Kind: nip34.KindStatusOpen, RootID: patchEventID},
{Event: &nip01.Event{PubKey: ownerPubkeyHex, CreatedAt: 1_700_000_500}, Kind: nip34.KindStatusApplied, RootID: patchEventID, AcceptedRevisionID: revisionEventID},
{Event: &nip01.Event{PubKey: strangerPubkeyHex, CreatedAt: 1_700_001_000}, Kind: nip34.KindStatusClosed, RootID: patchEventID}, // newer, but not authorized -- ignored
}
resolved := nip34.ResolveStatus(fetched, contributorPubkeyHex, []string{maintainerPubkeyHex, ownerPubkeyHex})
if resolved == nil {
panic("no authorized status found")
}
fmt.Println("resolved status kind:", resolved.Kind) // KindStatusApplied
// A patch revision inherits its root's resolved status, unless the
// root was merged and this wasn't the accepted revision -- then it's
// implicitly closed.
effective := nip34.ResolveRevisionStatus(revisionEventID, resolved)
fmt.Println("revision's effective status:", effective) // KindStatusApplied: it matches AcceptedRevisionID
}The git-hosting analogue of a NIP-65 relay list or NIP-B7 Blossom server list -- the grasp servers a user prefers for NIP-34 activity, in order:
package main
import (
"fmt"
"github.com/ohstr/nmilat/nip34"
)
func main() {
ev := nip34.NewGraspServerList(pubkeyHex, []string{
"wss://relay.ngit.dev",
"wss://grasp.example",
})
if err := ev.Sign(privateKeyHex); err != nil {
panic(err)
}
list, err := nip34.ParseGraspServerList(ev)
if err != nil {
panic(err)
}
fmt.Println("preferred grasp servers, in order:", list.Servers)
}package main
import (
"fmt"
"github.com/ohstr/nmilat/nip19"
"github.com/ohstr/nmilat/nip34"
)
func main() {
// Form 1: "nostr://<naddr>" -- wraps the repository announcement's
// naddr (see "Encode & decode entities" above).
naddr, err := nip19.EncodeAddr(nip19.EntityPointer{
Identifier: "ngit",
PublicKey: pubkeyHex,
Kind: nip34.KindRepositoryAnnouncement,
Relays: []string{"wss://relay.ngit.dev"},
})
if err != nil {
panic(err)
}
naddrForm := nip34.BuildCloneURLFromAddr(naddr)
fmt.Println(naddrForm) // nostr://naddr1...
parsed, err := nip34.ParseCloneURL(naddrForm)
if err != nil {
panic(err)
}
pointer, err := parsed.ResolveAddr()
if err != nil {
panic(err)
}
fmt.Println(pointer.Identifier, pointer.PublicKey, pointer.Kind)
// Form 2/3: "nostr://<npub|nip05>/[<relay-hint>/]<identifier>" -- more
// readable, resolved by looking up the owner's relay list instead of
// embedding one.
npub, err := nip19.EncodePublicKey(pubkeyHex)
if err != nil {
panic(err)
}
readableForm := nip34.BuildCloneURL(npub, "relay.ngit.dev", "ngit")
fmt.Println(readableForm) // nostr://npub1.../relay.ngit.dev/ngit
parsed2, err := nip34.ParseCloneURL(readableForm)
if err != nil {
panic(err)
}
fmt.Println(parsed2.Owner, parsed2.RelayHint, parsed2.Identifier)
// A NIP-05 identifier works as the owner too, and the relay hint is
// optional:
nip05Form := nip34.BuildCloneURL("dev@example.com", "", "ngit")
fmt.Println(nip05Form) // nostr://dev@example.com/ngit
}The "server/client" side of NIP-34 is just nip01's generic filter
builder plus relay/client, the same as any other NIP here -- there's no
nip34/client package, since NIP-34 has no second transport to dial:
package main
import (
"context"
"fmt"
"net/url"
"github.com/ohstr/nmilat/nip01"
"github.com/ohstr/nmilat/nip34"
relayclient "github.com/ohstr/nmilat/relay/client"
)
func main() {
relayURL, _ := url.Parse("wss://relay.ngit.dev")
// Every content kind a repository's activity can arrive as, filtered
// by the repo's own "a" tag address (the same one NewPatch/NewIssue/
// NewPullRequest/NewStatus were given as RepoAddress).
filter := nip01.NewFilter().
WithKinds(
nip34.KindPatch,
nip34.KindPullRequest,
nip34.KindPullRequestUpdate,
nip34.KindIssue,
nip34.KindStatusOpen,
nip34.KindStatusApplied,
nip34.KindStatusClosed,
nip34.KindStatusDraft,
).
WithTag("a", repoAddr)
events, err := relayclient.ReadEventsFromRelay(context.Background(), relayURL, nip01.NewSubscriptionFilterGroup(filter))
if err != nil {
panic(err)
}
for _, ev := range events {
if err := ev.Verify(); err != nil {
continue // bad signature, bad ID, or malformed -- skip it
}
switch ev.Kind {
case nip34.KindPatch:
patch, err := nip34.ParsePatch(ev)
if err == nil {
fmt.Println("patch:", patch.Commit)
}
case nip34.KindPullRequest:
pr, err := nip34.ParsePullRequest(ev)
if err == nil {
fmt.Println("PR:", pr.Subject)
}
case nip34.KindPullRequestUpdate:
upd, err := nip34.ParsePullRequestUpdate(ev)
if err == nil {
fmt.Println("PR update:", upd.Commit)
}
case nip34.KindIssue:
issue, err := nip34.ParseIssue(ev)
if err == nil {
fmt.Println("issue:", issue.Subject)
}
default:
if nip34.IsStatusKind(ev.Kind) {
status, err := nip34.ParseStatus(ev)
if err == nil {
fmt.Println("status for", status.RootID, "->", status.Kind)
}
}
}
}
// Replies (kind:1111) aren't repo-scoped by an "a" tag -- they're
// threaded off the issue/patch/PR event directly -- so subscribe to
// them by root instead:
replyFilter := nip01.NewFilter().WithKinds(1111).WithTag("E", issueEventID)
replies, err := relayclient.ReadEventsFromRelay(context.Background(), relayURL, nip01.NewSubscriptionFilterGroup(replyFilter))
if err != nil {
panic(err)
}
for _, ev := range replies {
if reply, err := nip34.ParseReply(ev); err == nil {
fmt.Println("reply:", reply.Content)
}
}
}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 + testUnlicense — public domain.