nostr 2.0.0
nostr: ^2.0.0 copied to clipboard
Pure-Dart Nostr protocol library. Events, signing, NIP-44 encryption, gift wrap, and 35+ NIPs. Transport-agnostic, Flutter Web compatible.
2.0.0 #
First major rewrite since v1.5.0. The library is now pure-protocol (no transport / WebSocket dependency), Flutter Web compatible, and spec-aligned against the upstream nostr-protocol/nips master. NIP-04 plaintext DMs are gone, every NIP has typed parse output, and all crypto runs through Schnorr / Encryption (no direct bip340).
Breaking Changes #
NIP-04 removed — Use Nip17 / DirectMessage (NIP-17 over NIP-59 gift wrap) instead.
Parameter privkey renamed to secretKey across the entire API (Event.from, all NIP encode methods, Nip59.wrap/unwrap, etc.)
Core API:
| Before | After |
|---|---|
Keychain(privkey) |
Keys(privkey) |
keychain.private |
keys.secret |
Event.fromJson(Map) |
Event.fromMap(Map) |
Event.toJson() → Map |
Event.toMap() |
Event.deserialize(dynamic) |
Event.deserialize(String) |
Event mutable fields (late) |
Event immutable (final); use Event.copyWith(...) or Event.unsigned(...) |
Filter.fromJson(Map) |
Filter.fromMap(Map) |
Filter.toJson() → Map |
Filter.toMap() |
Request('id', [filter]) |
Request(subscriptionId: 'id', filters: [filter]) |
Filter(e: [...]) |
Filter(eTags: [...]) |
Filter(p: [...]) |
Filter(pTags: [...]) |
Filter(a: [...]) |
Filter(aTags: [...]) |
Zap.request(amount: int) |
Zap.request(amount: BigInt) |
ZapRequestData.amount: int? |
ZapRequestData.amount: BigInt? |
MessageType.name |
MessageType.label |
generate64RandomHexChars() |
generateRandomHex() |
Event.fromJson/toJsonandFilter.fromJson/toJsonnow match the rest of the library:fromJson(String)returns an Event/Filter,toJson()returns a JSON string. The Map variants live on the newfromMap/toMapnames. SeeMIGRATION.md§2 + §22 for details.
Eventis now fully immutable — every field isfinal. UseEvent.unsigned(...)to build an event with a precomputed id and empty sig (NIP-17 rumors, NIP-13 mining probes), andevent.copyWith(...)to derive a modified copy. The old "mutatepartialEvent.id = ..." idiom no longer compiles. SeeMIGRATION.md§23.
Zap.request/anonymousRequest/privateRequestnow takeamount: BigInt?(wasint?).ZapRequestData.amountis alsoBigInt?. This keeps precision for amounts > 2^53 millisats when the library is compiled to JavaScript (Flutter Web). SeeMIGRATION.md§24.
NIP classes renamed — domain name is now the primary class, Nip* is the alias:
| Before | After | Alias |
|---|---|---|
Nip1 |
Note |
typedef Nip1 = Note |
Nip2 |
FollowList |
typedef Nip2 = FollowList |
Nip5 |
DnsIdentifier |
typedef Nip5 = DnsIdentifier |
Nip9 |
Deletion |
typedef Nip9 = Deletion |
Nip10 |
Threading |
typedef Nip10 = Threading |
Nip13 |
ProofOfWork |
typedef Nip13 = ProofOfWork |
Nip17 |
DirectMessage |
typedef Nip17 = DirectMessage |
Nip18 |
Repost |
typedef Nip18 = Repost |
Nip19 |
Bech32Entity |
typedef Nip19 = Bech32Entity |
Nip20 |
CommandResult |
typedef Nip20 = CommandResult |
Nip21 |
NostrUri |
typedef Nip21 = NostrUri |
Nip22 |
Comment |
typedef Nip22 = Comment |
Nip23 |
Article |
typedef Nip23 = Article |
Nip25 |
Reaction |
typedef Nip25 = Reaction |
Nip28 |
PublicChat |
typedef Nip28 = PublicChat |
Nip29 |
Group |
typedef Nip29 = Group |
Nip32 |
Label |
typedef Nip32 = Label |
Nip38 |
UserStatus |
typedef Nip38 = UserStatus |
Nip42 |
RelayAuth |
typedef Nip42 = RelayAuth |
Nip44 |
Encryption |
typedef Nip44 = Encryption |
Nip46 |
NostrConnect |
typedef Nip46 = NostrConnect |
Nip47 |
WalletConnect |
typedef Nip47 = WalletConnect |
Nip51 |
UserList |
typedef Nip51 = UserList |
Nip53 |
LiveActivity |
typedef Nip53 = LiveActivity |
Nip57 |
Zap |
typedef Nip57 = Zap |
Nip59 |
GiftWrap |
typedef Nip59 = GiftWrap |
Nip65 |
RelayList |
typedef Nip65 = RelayList |
Nip72 |
ModeratedCommunity |
typedef Nip72 = ModeratedCommunity |
Nip89 |
AppHandler |
typedef Nip89 = AppHandler |
Methods renamed — encode()→create(), decode()→parse(), spec-aligned verbs:
| Before | After |
|---|---|
Nip1.encodeTextNote() |
Note.create() |
Nip1.encodeSetMetadata() |
Note.setMetadata() |
Nip1.decodeTextNote() |
Note.parse() |
Nip2.encode() |
FollowList.create() |
Nip2.decode() |
FollowList.parse() |
Nip5.encode() |
DnsIdentifier.create() |
Nip5.decode() |
DnsIdentifier.parse() |
Nip9.encode() |
Deletion.create() |
Nip9.decode() |
Deletion.parse() |
Nip10.fromTags(tags) |
Threading.parseTags(tags) |
Nip25.encode() |
Reaction.create() |
Nip25.decode() |
Reaction.parse() |
Nip28.createChannel() |
PublicChat.channel() |
Nip28.setChannelMetaData() |
PublicChat.channelMetadata() |
Nip28.sendChannelMessage() |
PublicChat.channelMessage() |
Nip28.hideChannelMessage() |
PublicChat.hideMessage() |
Nip28.muteUser() |
PublicChat.muteUser() |
Nip28.getChannelCreation() |
PublicChat.parseChannel() |
Nip28.getChannelMetadata() |
PublicChat.parseMetadata() |
Nip28.getChannelMessage() |
PublicChat.parseMessage() |
Nip28.getMessageHidden() |
PublicChat.parseHidden() |
Nip28.getUserMuted() |
PublicChat.parseMuted() |
Nip47.encodeRequest() |
WalletConnect.request() |
Nip47.decodeInfo() |
WalletConnect.parseInfo() |
Nip51.createMutePeople() |
UserList.mutePeople() |
Nip51.createPinEvent() |
UserList.pinEvent() |
Nip51.createCategorizedPeople() |
UserList.categorizedPeople() |
Nip51.createCategorizedBookmarks() |
UserList.categorizedBookmarks() |
Nip51.peoplesToTags() |
UserList.contactsToTags() |
Nip51.peoplesToContent() |
UserList.contactsToContent() |
Nip51.getLists(event, secretKey) |
UserList.parse(event, secretKey: ...) |
Nip57.encodeZapRequest() |
Zap.request() |
Nip57.decodeZapReceipt() |
Zap.parseReceipt() |
All model classes renamed with Data suffix:
| Before | After |
|---|---|
Note |
NoteData |
Profile |
ProfileData |
DNS |
DnsData |
DeletionRequest |
DeletionRequestData |
Reaction (model) |
ReactionData |
Repost (model) |
RepostData |
Comment (model) |
CommentData |
Nip23Article |
ArticleData |
Channel |
ChannelData |
ChannelMessage |
ChannelMessageData |
ChannelMessageHidden |
ChannelMessageHiddenData |
ChannelUserMuted |
ChannelUserMutedData |
UserStatus (model) |
UserStatusData |
LiveActivity (model) |
LiveActivityData |
ZapRequest / ZapReceipt |
ZapRequestData / ZapReceiptData |
ShareableIdentifiers |
ShareableIdentifierData |
UserList |
UserListData |
Event-kind constants standardised to kindXxx prefix on every NIP
class (e.g. Zap.kindZapRequest, WalletConnect.kindWalletInfo,
ModeratedCommunity.kindCommunity, AppHandler.kindHandlerInfo,
NostrConnect.kindNostrConnect, Deletion.kindDeletion).
Signature changes (same name, different shape):
| Before | After |
|---|---|
Close.deserialize(dynamic) |
Close.deserialize(String payload) |
Eose.deserialize(dynamic) |
Eose.deserialize(String payload) |
Request.deserialize(dynamic) |
Request.deserialize(String payload) |
Message.deserialize(dynamic) |
Message.deserialize(String payload) |
Nip20.deserialize(dynamic) |
CommandResult.deserialize(String payload) |
MessageType.fromName(String) |
MessageType.from(String) |
Keychain.sign(String message) |
Keys.sign({required String message}) |
UserList.parse(event, privkey) (sync) |
UserList.parse(event, {required secretKey}) (async, named arg) |
UserList.fromContent(...) (sync) |
UserList.fromContent(...) (async) |
Event.from(secretKey, kind, tags, content, createdAt) |
Event.from({required kind, required content, required secretKey, tags?, createdAt?, …}) |
Removed without direct replacement:
| Removed | Migration |
|---|---|
Keychain class |
Use Keys |
Keychain.verify(pubkey, message, sig) |
Use Schnorr.verify(...) |
Nip4 / EncryptedDirectMessage (NIP-04) |
Use DirectMessage (NIP-17 over NIP-59) |
Nip19.encodePubkey/encodePrivkey/encodeNote |
Use Bech32Entity.encode(prefix: ..., data: ...) |
Nip19.decodePubkey/decodePrivkey/decodeNote |
Use Bech32Entity.decode(payload: ...) or Bech32Entity.decodeAny(...) |
kepler.dart, crypto/operator.dart, crypto/nip_004.dart |
Internal NIP-04 helpers, gone with NIP-04 |
Contact.aliasPubKey field |
Field removed; Contact(pubkey, mainRelay, petName) is 3-arg |
Other breaking changes:
| Before | After |
|---|---|
Filter fields mutable |
Filter fields final, constructor const |
| All model positional ctors | All model named const constructors |
bip340 re-exported via package:nostr |
Internal; use Schnorr.sign / verify / derivePublicKey |
nip_044_utils.dart re-exported |
Internal; use Encryption.encrypt / decrypt |
New Features #
Keys.nsec/Keys.npubgettersKeys()now validates exact 64-char hex lengthMessageType.closed(CLOSED relay message per NIP-01)FollowList.create()(kind-3 follow list events)Article.create()(kind-30023 / 30024 long-form events)DnsIdentifier.verify()DNS identity verification with no-redirect per specDnsIdentifier.verificationUrl()helperDeletionnow supportsatags (addressable events) andktags (kind indication)NostrUri.encode()rejectsnsecidentifiers per specUserList.parse()handles both plaintext JSON and NIP-44 encrypted content- Semantic typedef aliases for every NIP (
TextNote,Profile,DirectMessage, etc.) - New NIP implementations since v1.5.0: 11, 17, 18, 22, 23, 25, 27, 29, 32, 38, 40, 42, 44, 46, 47, 51 (expanded), 53, 57, 58, 59, 65, 72, 89, 94, 98
- Top-level
Tag = List<String>andTags = List<Tag>typedefs Filter.tagFilters: Map<String, List<String>>?— generic single-letter tag filter map (#d/#t/#k/#r, etc.).Filter.fromJsoncollects every#Xkey into this map;eTags/aTags/pTagsstill take precedence when set.- NIP-13 mining —
nonceTag(value, target),targetFromTag,meetsTarget(event), andmine(difficulty, kind, content, secretKey, ...)for actually producing PoW events. Previously onlycountLeadingZeroeswas exposed. - NIP-11 — relay information document:
RelayInfo.fetch(relayUrl)returnsRelayInfoDatawithsupportedNips,limitation,software,version, and operator contact fields. URL scheme rewritten fromwss:///ws://tohttps:///http://automatically. Tolerant of wrong-typed fields commonly seen in the wild. - NIP-94 — file metadata events (kind 1063).
- NIP-98 — HTTP auth (kind 27235):
create,validate,payloadHash,toAuthHeader/fromAuthHeader. - NIP-58 Profile Badges migrated to kind 10008 per spec;
parseProfileBadgesaccepts the legacy kind 30008 form too. - NIP-29 write helpers (
message,threadRoot,threadReply,joinRequest,leaveRequest) and parsers forparseAdmins(kind 39001) /parseMembers(kind 39002). MissingTagExceptionpermissive mode — every parser whose NIP defines spec-required tags (NIPs 22, 23, 29, 38, 53, 57, 58, 72, 89, 94, 98) now accepts{bool permissive = false}; in permissive mode the missing-tag set is recorded on<Data>.missingTagsand<Data>.isCompleteinstead of throwing, so consumers can still display whatever is salvageable on the ~31 % of real-world events that violate spec requirements.- rust-nostr cross-implementation test vectors (NIP-19, 13, 21, 44, 59, 05, 09)
- Real-world relay event fixture tests for 20+ event kinds
Bug Fixes & Spec Compliance #
HIGH (security / correctness)
- NIP-44 (
unpad): enforcepadded.length == 2 + calcPaddedLen(unpaddedLen)per spec pseudocode — prevents accepting malleable / over-sized padded buffers. - NIP-19 (
encodeShareableIdentifiers/decodeShareableIdentifiers): switch naddr identifier and relay byte encoding fromString.codeUnits(UTF-16) toutf8.encode/utf8.decode. Matches rust-nostr and nostr-tools; unblocks non-ASCIId-tags (e.g.café,日本, emoji). - NIP-98 (
fromAuthHeader,validate):fromAuthHeaderverifies id + signature on the decoded event.validatecallsevent.isValid()first for defense in depth. - NIP-42 (
validate) and NIP-59 (unwrap) callevent.isValid()at the top so forged events are caught before any request- or decrypt-specific check.
Spec gaps closed
- NIP-22 (
parse): throwsMissingTagExceptionwhen requiredK,k, root-scope (E/A/I), or parent (e/a/i) tags are absent. - NIP-25 (
parse): when multiplee/ptags exist, the target is the LAST one per spec. Surfaces theerelay hint and optionala. - NIP-29 (
parseMetadata): throws on missingdtag (group identifier — required by NIP-01 for addressable events). - NIP-65 (
parse): unknown markers fall back to read+write instead of silently dropping the relay. - NIP-72 (
approval):approvedEventJsonrequired when referenced viae(spec MUST). AddedapprovedEventCoordfor addressable posts; rejects when neither (or both) ofe/ais provided. - NIP-89 (
parseHandlerInfo): platform-handler detection uses a positive allowlist (web,ios,android,iphone,ipad,macos,linux,windows) instead of a brittle exclude-list. - NIP-19:
encodeShareableIdentifiersrequiresauthorandkindfornaddr. 5000-char cap enforced on encode and decode. AddeddecodeAny()dispatcher.
Exception contract — all errors are now NostrException
Every public deserialization / decode entrypoint that previously could
leak a raw FormatException, _TypeError, or package:bech32
exception now wraps it as a NostrException subclass, matching the
documented contract in error.dart. Callers only need on NostrException.
Bech32Entity.decode/decodeAny/encode— wrappackage:bech32errors (InvalidChecksum,MixedCase,TooShortChecksum,TooLong,InvalidSeparator) and non-hex input asDeserializationException. The underlying message is suppressed in the wrapped error to avoid echoing candidate secrets to logs.Schnorr.derivePublicKey/sign/verify— non-hex inputs now throwInvalidKeyExceptioninstead of leakingFormatExceptionfromhex.decode.Event.fromJson— bad JSON or non-object payloads throwDeserializationException.Event.deserialize— validates the wire frame starts with"EVENT"and has the right shape; previously accepted any tag silently and threw_TypeErroron shape mismatches.Close/Eose/Request/Message/CommandResult.deserialize — wrapjson.decodefailures and validate the frame tag + shape before any cast.Filter.fromJson— typed validation on every field; passing e.g.kinds: "not a list"now throwsDeserializationExceptioninstead of_TypeError.PublicChat.parseChannel/parseMetadata— non-JSON or non-object content throwsDeserializationException.ProofOfWork.countLeadingZeroes— non-hex input throwsDeserializationExceptioninstead ofFormatExceptionfromint.parse.GiftWrap.unwrap— wrapsjson.decodefailure on the inner rumor payload asDeserializationException.ModeratedCommunity.parseApproval/AppHandler.parseHandlerInfo/Zap.parseReceipt— a JSON array (or any non-object) in a content / description slot no longer raises_TypeError; the optional embedded field is left null, matching the existing fail-soft contract.
Error messages no longer echo candidate secrets
Keys(...)rejection no longer includes the input string —package:bech32'sMixedCaseerror message would otherwise leak the candidate (e.g. a confused caller passing their nsec) into logs.InvalidNostrUriExceptionmessage no longer embeds the input (the raw value remains on the typed.inputfield for consumers that genuinely need it).
Performance
- NIP-13
mineno longer Schnorr-signs every nonce iteration. Mining is hash-bound; computing the candidate event id from the canonical serialization is ~1000× faster than signing. The winning nonce is signed once at the end. Previously high-difficulty PoW was effectively unreachable. - NIP-19
encodeShareableIdentifiersno longer O(n²) in relay count. Rebuilt the TLV with aStringBufferinstead of repeated string concat — 100 000 relays went from ~80 s to ~300 ms (after which the 5000-char length cap rejects).
Real bugs
- NIP-28 (
parseChannel/parseMetadata): channel content with the spec-definedrelaysarray no longer crashesMap<String, String>.from.ChannelDataexposesrelays: List<String>separately from stringadditional. - NIP-21 (
decode): rejectsnostr:nsec1…and any prefix outside{npub, note, nprofile, nevent, naddr}. - Tag-bounds bugs (RangeError on empty tags) fixed in
nip_002.dart,nip_028.dart,nip_065.dart. - fix(nip59):
_randomPastTimestampnow covers the full 2-day window (was ~172 seconds) - fix(nip10): bounds check on tags without markers (prevents
RangeError) - fix(nip44): stale error message expectations in test vectors — the
decrypt-fail vectors 004–008 were exercising a vestigial helper
that always tripped a fake version check; they now go through
Encryption.decryptand validate canonical MAC + padding errors - fix(nip28): safe null handling instead of force-unwraps on malformed events
- fix(nip05):
isValidNamenow allows hyphens and dots per spec - fix(nip05):
parsewraps malformed-JSON content asDeserializationExceptionand no longer echoes the content in the error message - fix(nip19): silent uint32 truncation on
kindparameter ofencodeShareableIdentifiers—kind = 2^32would round-trip to0. Now rejects out-of-range kinds withInvalidArgumentException - fix(nip57):
Zap.request/anonymousRequest/privateRequestreject negativeamountvalues (spec is unsigned millisats) - fix(nip01):
NoteData.threadtyped as non-nullableThread(wasThread?) —Note.parsewas always returning a non-null sentinel, contradicting the declared type. See MIGRATION.md. - fix(nip29):
parseMetadatanow reportsisPrivate,isClosed, andisBroadcastflag presence in addition toisOpen/isPublic - fix(nip23): replaced private
_getTagValuehelpers with sharedfindTagValue - fix: copy-paste doc errors in
EoseandNip20
Constants
Repost.kindRepost(= 6) andRepost.kindGenericRepost(= 16) added so callers can avoid magic numbers when parsing NIP-18 events.
Dependencies
pointycastleconstraint loosened to>=3.7.3 <5.0.0— picks up v4.0.0 (now resolved) which drops the discontinuedjstransitive package.
Spec tightening
- NIP-51 (
UserList.parse) now rejects events whosekindis outside the list ranges (10000-10999or30000-39999) withInvalidKindException. Previously it accepted any kind silently and would happily mangle a NIP-23 article into a list.
Architecture #
bip340direct imports removed fromevent.dartandkeys.dart; both route throughSchnorr. AddedSchnorr.derivePublicKeywith 32-byte input validation.Event.isValidcatchesInvalidKeyExceptionand returns false instead of propagating.nip_044_utils.dartno longer re-exported fromnostr.dart. The raw crypto primitives (pad,unpad,chacha20,hkdf,calculateMac,parsePayload, etc.) are easy to misuse and now stay internal. UseEncryption.encrypt/Encryption.decrypt. Tests that need the primitives import the file path directly.
1.5.0 #
- feat: add EOSE class to obtain subscriptionId (#41)
1.4.3 #
- refactor: Message.type is made of an MessageType enum instead of a String
1.4.2 #
- NIP50: search filter
1.4.1 #
- new a filter
- Upgrade bip340 dependency
1.4.0 #
- NIP 04 Encrypted Direct Message
- NIP 05 Mapping Nostr keys to DNS-based internet identifiers
- NIP 10 Conventions for clients' use of e and p tags in text events
- NIP 15 End of Stored Events Notice
- NIP 19 bech32-encoded entities
- NIP 20 Command Results
- NIP 28 Public Chat
- NIP 51 Lists
1.3.4 #
- fix: pending bip340 issue
- test: Update test to check public key
- refactor: Event partial and from to factories
1.3.3 #
- add comments about verify and fix typo
- nip 002 implementation, unit tests, examples and documentation
- Event.partial to init an empty event that you validate later, documentation
1.3.2 #
- refactor: Event with optional verification
- remove tests with encoding problem
- improve coverage
1.3.1 #
- fix: Inconsitency in events is breaking tags
1.3.0 #
- add Close (+ unit tests)
- add Message wrapper deserializer (+ unit tests)
- Documentation
1.2.0 #
- add Filters (+ unit tests)
- add Request (+ unit tests)
- Documentation
1.1.0 #
- fix Event.fromJson
- add subscriptionId
- deserialization of NOSTR formatted events with or without subscription_id
- add unit tests for Event to improve coverage
- Create Keychain container for private/public keys to encapsulate bip340 and add handy methods.
- Documentation
1.0.1 #
- Fix createdAt initialization
- Add asserts
- Code comments
1.0.0 #
- Initial version.