├── .github └── workflows │ └── dart-test.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── analysis_options.yaml ├── example ├── close_example.dart ├── message_example.dart ├── nip_002_example.dart ├── nostr_example.dart └── request_filters_example.dart ├── lib ├── nostr.dart └── src │ ├── close.dart │ ├── crypto │ ├── kepler.dart │ ├── nip_004.dart │ └── operator.dart │ ├── eose.dart │ ├── event.dart │ ├── filter.dart │ ├── keys.dart │ ├── message.dart │ ├── nips │ ├── nip_001.dart │ ├── nip_002.dart │ ├── nip_004.dart │ ├── nip_005.dart │ ├── nip_009.dart │ ├── nip_010.dart │ ├── nip_013.dart │ ├── nip_017.dart │ ├── nip_019.dart │ ├── nip_019_utils.dart │ ├── nip_020.dart │ ├── nip_021.dart │ ├── nip_023.dart │ ├── nip_028.dart │ ├── nip_044.dart │ ├── nip_044_utils.dart │ ├── nip_051.dart │ └── nip_059.dart │ ├── request.dart │ ├── schnorr.dart │ └── utils.dart ├── pubspec.yaml └── test ├── close_test.dart ├── eose_test.dart ├── event_test.dart ├── filter_test.dart ├── keys_test.dart ├── message_test.dart ├── nips ├── nip_002_test.dart ├── nip_004_test.dart ├── nip_005_test.dart ├── nip_009_test.dart ├── nip_010_test.dart ├── nip_013_test.dart ├── nip_017_test.dart ├── nip_019_test.dart ├── nip_020_test.dart ├── nip_021_test.dart ├── nip_023_test.dart ├── nip_028_test.dart ├── nip_044_test.dart ├── nip_051_test.dart └── nip_059_test.dart └── request_test.dart /.github/workflows/dart-test.yml: -------------------------------------------------------------------------------- 1 | name: nostr CI 2 | 3 | on: 4 | push: 5 | branches: [main, develop] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | tests: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - name: Checkout the code 15 | uses: actions/checkout@v3 16 | 17 | - name: Install and set Flutter version 18 | uses: subosito/flutter-action@v2 19 | with: 20 | channel: 'stable' 21 | 22 | - name: Upgrade flutter 23 | run: flutter pub upgrade 24 | 25 | - name: Restore packages 26 | run: flutter pub get 27 | 28 | - name: Analyze 29 | run: flutter analyze 30 | 31 | - name: Run tests 32 | run: flutter test --coverage 33 | 34 | - name: Download codcov uploader 35 | run: curl -Os https://uploader.codecov.io/latest/linux/codecov 36 | shell: bash 37 | 38 | - name: Set codecov uploader executable 39 | run: chmod +x codecov 40 | shell: bash 41 | 42 | - name: Execute codecov uploader 43 | run: ./codecov 44 | shell: bash -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Files and directories created by pub. 2 | .dart_tool/ 3 | .packages 4 | *.swp 5 | x 6 | tags 7 | 8 | # Conventional directory for build outputs. 9 | build/ 10 | 11 | # Omit committing pubspec.lock for library packages; see 12 | # https://dart.dev/guides/libraries/private-files#pubspeclock. 13 | pubspec.lock 14 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 1.0.0 2 | 3 | - Initial version. 4 | 5 | ## 1.0.1 6 | 7 | - Fix createdAt initialization 8 | - Add asserts 9 | - Code comments 10 | 11 | ## 1.1.0 12 | 13 | - fix Event.fromJson 14 | - add subscriptionId 15 | - deserialization of NOSTR formatted events with or without subscription_id 16 | - add unit tests for Event to improve coverage 17 | - Create Keychain container for private/public keys to encapsulate bip340 and add handy methods. 18 | - Documentation 19 | 20 | ## 1.2.0 21 | 22 | - add Filters (+ unit tests) 23 | - add Request (+ unit tests) 24 | - Documentation 25 | 26 | ## 1.3.0 27 | 28 | - add Close (+ unit tests) 29 | - add Message wrapper deserializer (+ unit tests) 30 | - Documentation 31 | 32 | ## 1.3.1 33 | 34 | - fix: Inconsitency in events is breaking tags 35 | 36 | ## 1.3.2 37 | 38 | - refactor: Event with optional verification 39 | - remove tests with encoding problem 40 | - improve coverage 41 | 42 | ## 1.3.3 43 | 44 | - add comments about verify and fix typo 45 | - nip 002 implementation, unit tests, examples and documentation 46 | - Event.partial to init an empty event that you validate later, documentation 47 | 48 | ## 1.3.4 49 | 50 | - fix: pending bip340 issue 51 | - test: Update test to check public key 52 | - refactor: Event partial and from to factories 53 | 54 | ## 1.4.0 55 | 56 | - NIP 04 Encrypted Direct Message 57 | - NIP 05 Mapping Nostr keys to DNS-based internet identifiers 58 | - NIP 10 Conventions for clients' use of e and p tags in text events 59 | - NIP 15 End of Stored Events Notice 60 | - NIP 19 bech32-encoded entities 61 | - NIP 20 Command Results 62 | - NIP 28 Public Chat 63 | - NIP 51 Lists 64 | 65 | ## 1.4.1 66 | 67 | - [new **a** filter](https://github.com/nostr-protocol/nips/commit/e50bf508d9014cfb19bfa8a5c4ec88dc4788d490) 68 | - Upgrade bip340 dependency 69 | 70 | ## 1.4.2 71 | 72 | - NIP50: search filter 73 | 74 | ## 1.4.3 75 | 76 | - refactor: Message.type is made of an MessageType enum instead of a String 77 | 78 | ## 1.5.0 79 | 80 | - feat: add EOSE class to obtain subscriptionId (#41) 81 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU LESSER GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | 9 | This version of the GNU Lesser General Public License incorporates 10 | the terms and conditions of version 3 of the GNU General Public 11 | License, supplemented by the additional permissions listed below. 12 | 13 | 0. Additional Definitions. 14 | 15 | As used herein, "this License" refers to version 3 of the GNU Lesser 16 | General Public License, and the "GNU GPL" refers to version 3 of the GNU 17 | General Public License. 18 | 19 | "The Library" refers to a covered work governed by this License, 20 | other than an Application or a Combined Work as defined below. 21 | 22 | An "Application" is any work that makes use of an interface provided 23 | by the Library, but which is not otherwise based on the Library. 24 | Defining a subclass of a class defined by the Library is deemed a mode 25 | of using an interface provided by the Library. 26 | 27 | A "Combined Work" is a work produced by combining or linking an 28 | Application with the Library. The particular version of the Library 29 | with which the Combined Work was made is also called the "Linked 30 | Version". 31 | 32 | The "Minimal Corresponding Source" for a Combined Work means the 33 | Corresponding Source for the Combined Work, excluding any source code 34 | for portions of the Combined Work that, considered in isolation, are 35 | based on the Application, and not on the Linked Version. 36 | 37 | The "Corresponding Application Code" for a Combined Work means the 38 | object code and/or source code for the Application, including any data 39 | and utility programs needed for reproducing the Combined Work from the 40 | Application, but excluding the System Libraries of the Combined Work. 41 | 42 | 1. Exception to Section 3 of the GNU GPL. 43 | 44 | You may convey a covered work under sections 3 and 4 of this License 45 | without being bound by section 3 of the GNU GPL. 46 | 47 | 2. Conveying Modified Versions. 48 | 49 | If you modify a copy of the Library, and, in your modifications, a 50 | facility refers to a function or data to be supplied by an Application 51 | that uses the facility (other than as an argument passed when the 52 | facility is invoked), then you may convey a copy of the modified 53 | version: 54 | 55 | a) under this License, provided that you make a good faith effort to 56 | ensure that, in the event an Application does not supply the 57 | function or data, the facility still operates, and performs 58 | whatever part of its purpose remains meaningful, or 59 | 60 | b) under the GNU GPL, with none of the additional permissions of 61 | this License applicable to that copy. 62 | 63 | 3. Object Code Incorporating Material from Library Header Files. 64 | 65 | The object code form of an Application may incorporate material from 66 | a header file that is part of the Library. You may convey such object 67 | code under terms of your choice, provided that, if the incorporated 68 | material is not limited to numerical parameters, data structure 69 | layouts and accessors, or small macros, inline functions and templates 70 | (ten or fewer lines in length), you do both of the following: 71 | 72 | a) Give prominent notice with each copy of the object code that the 73 | Library is used in it and that the Library and its use are 74 | covered by this License. 75 | 76 | b) Accompany the object code with a copy of the GNU GPL and this license 77 | document. 78 | 79 | 4. Combined Works. 80 | 81 | You may convey a Combined Work under terms of your choice that, 82 | taken together, effectively do not restrict modification of the 83 | portions of the Library contained in the Combined Work and reverse 84 | engineering for debugging such modifications, if you also do each of 85 | the following: 86 | 87 | a) Give prominent notice with each copy of the Combined Work that 88 | the Library is used in it and that the Library and its use are 89 | covered by this License. 90 | 91 | b) Accompany the Combined Work with a copy of the GNU GPL and this license 92 | document. 93 | 94 | c) For a Combined Work that displays copyright notices during 95 | execution, include the copyright notice for the Library among 96 | these notices, as well as a reference directing the user to the 97 | copies of the GNU GPL and this license document. 98 | 99 | d) Do one of the following: 100 | 101 | 0) Convey the Minimal Corresponding Source under the terms of this 102 | License, and the Corresponding Application Code in a form 103 | suitable for, and under terms that permit, the user to 104 | recombine or relink the Application with a modified version of 105 | the Linked Version to produce a modified Combined Work, in the 106 | manner specified by section 6 of the GNU GPL for conveying 107 | Corresponding Source. 108 | 109 | 1) Use a suitable shared library mechanism for linking with the 110 | Library. A suitable mechanism is one that (a) uses at run time 111 | a copy of the Library already present on the user's computer 112 | system, and (b) will operate properly with a modified version 113 | of the Library that is interface-compatible with the Linked 114 | Version. 115 | 116 | e) Provide Installation Information, but only if you would otherwise 117 | be required to provide such information under section 6 of the 118 | GNU GPL, and only to the extent that such information is 119 | necessary to install and execute a modified version of the 120 | Combined Work produced by recombining or relinking the 121 | Application with a modified version of the Linked Version. (If 122 | you use option 4d0, the Installation Information must accompany 123 | the Minimal Corresponding Source and Corresponding Application 124 | Code. If you use option 4d1, you must provide the Installation 125 | Information in the manner specified by section 6 of the GNU GPL 126 | for conveying Corresponding Source.) 127 | 128 | 5. Combined Libraries. 129 | 130 | You may place library facilities that are a work based on the 131 | Library side by side in a single library together with other library 132 | facilities that are not Applications and are not covered by this 133 | License, and convey such a combined library under terms of your 134 | choice, if you do both of the following: 135 | 136 | a) Accompany the combined library with a copy of the same work based 137 | on the Library, uncombined with any other library facilities, 138 | conveyed under the terms of this License. 139 | 140 | b) Give prominent notice with the combined library that part of it 141 | is a work based on the Library, and explaining where to find the 142 | accompanying uncombined form of the same work. 143 | 144 | 6. Revised Versions of the GNU Lesser General Public License. 145 | 146 | The Free Software Foundation may publish revised and/or new versions 147 | of the GNU Lesser General Public License from time to time. Such new 148 | versions will be similar in spirit to the present version, but may 149 | differ in detail to address new problems or concerns. 150 | 151 | Each version is given a distinguishing version number. If the 152 | Library as you received it specifies that a certain numbered version 153 | of the GNU Lesser General Public License "or any later version" 154 | applies to it, you have the option of following the terms and 155 | conditions either of that published version or of any later version 156 | published by the Free Software Foundation. If the Library as you 157 | received it does not specify a version number of the GNU Lesser 158 | General Public License, you may choose any version of the GNU Lesser 159 | General Public License ever published by the Free Software Foundation. 160 | 161 | If the Library as you received it specifies that a proxy can decide 162 | whether future versions of the GNU Lesser General Public License shall 163 | apply, that proxy's public statement of acceptance of any version is 164 | permanent authorization for you to choose that version for the 165 | Library. 166 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![License: LGPL v3](https://img.shields.io/badge/License-LGPL_v3-blue.svg)](https://www.gnu.org/licenses/lgpl-3.0) 2 | [![nostr CI](https://github.com/ethicnology/dart-nostr/actions/workflows/dart-test.yml/badge.svg)](https://github.com/ethicnology/dart-nostr/actions/workflows/dart-test.yml) 3 | [![pub package](https://img.shields.io/pub/v/nostr.svg)](https://pub.dartlang.org/packages/nostr) 4 | [![codecov](https://codecov.io/gh/ethicnology/dart-nostr/branch/main/graph/badge.svg?token=RNIA9IIRB6)](https://codecov.io/gh/ethicnology/dart-nostr) 5 | # nostr 6 | A library for nostr protocol implemented in dart for flutter. 7 | 8 | [Dispute](https://github.com/ethicnology/dispute) is a basic nostr client written in flutter with this library that will show you an implementation. 9 | 10 | ## Getting started 11 | ```sh 12 | flutter pub add nostr 13 | ``` 14 | 15 | 16 | ## [NIPS](https://github.com/nostr-protocol/nips) 17 | - [x] [NIP 01 Basic protocol flow description](https://github.com/nostr-protocol/nips/blob/master/01.md) 18 | - [x] [NIP 02 Contact List and Petnames](https://github.com/nostr-protocol/nips/blob/master/02.md) 19 | - [x] [NIP 04 Encrypted Direct Message](https://github.com/nostr-protocol/nips/blob/master/04.md) 20 | - [x] [NIP 05 Mapping Nostr keys to DNS-based internet identifiers](https://github.com/nostr-protocol/nips/blob/master/05.md) 21 | - [x] [NIP 09 Event Deletion Request](https://github.com/nostr-protocol/nips/blob/master/09.md) 22 | - [x] [NIP 10 Conventions for clients' use of e and p tags in text events](https://github.com/nostr-protocol/nips/blob/master/10.md) 23 | - [x] [NIP 13 Proof of Work](https://github.com/nostr-protocol/nips/blob/master/13.md) 24 | - [x] [NIP 15 End of Stored Events Notice](https://github.com/nostr-protocol/nips/blob/master/15.md) 25 | - [x] [NIP 17 Private Direct Messages (Partial)](https://github.com/nostr-protocol/nips/blob/master/17.md) 26 | - [x] [NIP 19 bech32-encoded entities](https://github.com/nostr-protocol/nips/blob/master/19.md) 27 | - [x] [NIP 20 Command Results](https://github.com/nostr-protocol/nips/blob/master/20.md) 28 | - [x] [NIP 21 nostr: URI scheme](https://github.com/nostr-protocol/nips/blob/master/21.md) 29 | - [x] [NIP 23 Long-form Content](https://github.com/nostr-protocol/nips/blob/master/23.md) 30 | - [x] [NIP 28 Public Chat](https://github.com/nostr-protocol/nips/blob/master/28.md) 31 | - [x] [NIP 44 Encrypted Payloads (Versioned)](https://github.com/nostr-protocol/nips/blob/master/44.md) 32 | - [x] [NIP 50 Search Capability](https://github.com/nostr-protocol/nips/blob/master/50.md) 33 | - [x] [NIP 51 Lists](https://github.com/nostr-protocol/nips/blob/master/51.md) 34 | - [x] [NIP 59 Gift Wrap](https://github.com/nostr-protocol/nips/blob/master/59.md) 35 | 36 | 37 | ## Usage 38 | ### Events messages 39 | ```dart 40 | import 'dart:io'; 41 | import 'package:nostr/nostr.dart'; 42 | 43 | void main() async { 44 | // Use the Keys class to manipulate secret/public keys and use handy methods encapsulated from dart-bip340 45 | var keys = Keys( 46 | "5ee1c8000ab28edd64d74a7d951ac2dd559814887b1b9e1ac7c5f89e96125c12", 47 | ); 48 | assert(keys.public == 49 | "981cc2078af05b62ee1f98cff325aac755bf5c5836a265c254447b5933c6223b"); 50 | 51 | // or generate random keys 52 | var randomKeys = Keys.generate(); 53 | print(randomKeys.secret); 54 | 55 | // Instantiate an event with all the field 56 | String id = 57 | "4b697394206581b03ca5222b37449a9cdca1741b122d78defc177444e2536f49"; 58 | String pubkey = keys.public; 59 | int createdAt = 1672175320; 60 | int kind = 1; 61 | List> tags = []; 62 | String content = "Ceci est une analyse du websocket"; 63 | String sig = 64 | "797c47bef50eff748b8af0f38edcb390facf664b2367d72eb71c50b5f37bc83c4ae9cc9007e8489f5f63c66a66e101fd1515d0a846385953f5f837efb9afe885"; 65 | 66 | Event oneEvent = Event( 67 | id, 68 | pubkey, 69 | createdAt, 70 | kind, 71 | tags, 72 | content, 73 | sig, 74 | ); 75 | assert(oneEvent.id == 76 | "4b697394206581b03ca5222b37449a9cdca1741b122d78defc177444e2536f49"); 77 | 78 | // Create a partial event from nothing and fill it with data until it is valid 79 | var partialEvent = Event.partial(); 80 | assert(partialEvent.isValid() == false); 81 | partialEvent.createdAt = currentUnixTimestampSeconds(); 82 | partialEvent.pubkey = 83 | "981cc2078af05b62ee1f98cff325aac755bf5c5836a265c254447b5933c6223b"; 84 | partialEvent.id = partialEvent.getEventId(); 85 | partialEvent.sig = partialEvent.getSignature( 86 | "5ee1c8000ab28edd64d74a7d951ac2dd559814887b1b9e1ac7c5f89e96125c12", 87 | ); 88 | assert(partialEvent.isValid() == true); 89 | 90 | // Instantiate an event with a partial data and let the library sign the event with your secret key 91 | Event anotherEvent = Event.from( 92 | kind: 1, 93 | tags: [], 94 | content: "vi veri universum vivus vici", 95 | privkey: 96 | "5ee1c8000ab28edd64d74a7d951ac2dd559814887b1b9e1ac7c5f89e96125c12", // DO NOT REUSE THIS PRIVATE KEY 97 | ); 98 | 99 | assert(anotherEvent.pubkey == 100 | "981cc2078af05b62ee1f98cff325aac755bf5c5836a265c254447b5933c6223b"); 101 | 102 | // Connecting to a nostr relay using websocket 103 | WebSocket webSocket = await WebSocket.connect( 104 | 'wss://relay.nostr.info', // or any nostr relay 105 | ); 106 | // if the current socket fail try another one 107 | // wss://nostr.sandwich.farm 108 | // wss://relay.damus.io 109 | 110 | // Send an event to the WebSocket server 111 | webSocket.add(anotherEvent.serialize()); 112 | 113 | // Listen for events from the WebSocket server 114 | await Future.delayed(Duration(seconds: 1)); 115 | webSocket.listen((event) { 116 | print('Received event: $event'); 117 | }); 118 | 119 | // Close the WebSocket connection 120 | await webSocket.close(); 121 | } 122 | ``` 123 | 124 | ### Request messages and filters 125 | ```dart 126 | import 'dart:io'; 127 | import 'package:nostr/nostr.dart'; 128 | 129 | void main() async { 130 | // Create a subscription message request with one or many filters 131 | Request requestWithFilter = Request(generate64RandomHexChars(), [ 132 | Filter( 133 | kinds: [0, 1, 2, 7], 134 | since: 1674063680, 135 | limit: 450, 136 | ) 137 | ]); 138 | 139 | // Connecting to a nostr relay using websocket 140 | WebSocket webSocket = await WebSocket.connect( 141 | 'wss://relay.nostr.info', // or any nostr relay 142 | ); 143 | // if the current socket fail try another one 144 | // wss://nostr.sandwich.farm 145 | // wss://relay.damus.io 146 | 147 | // Send a request message to the WebSocket server 148 | webSocket.add(requestWithFilter.serialize()); 149 | 150 | // Listen for events from the WebSocket server 151 | await Future.delayed(Duration(seconds: 1)); 152 | webSocket.listen((event) { 153 | print('Received event: $event'); 154 | }); 155 | 156 | // Close the WebSocket connection 157 | await webSocket.close(); 158 | } 159 | ``` 160 | 161 | ### Close subscription 162 | ```dart 163 | import 'package:nostr/nostr.dart'; 164 | 165 | void main() async { 166 | String subscriptionId = generate64RandomHexChars(); 167 | var close1 = Close(subscriptionId); 168 | assert(close1.subscriptionId == subscriptionId); 169 | 170 | var close2 = Close(subscriptionId); 171 | assert(close2.serialize() == '["CLOSE","$subscriptionId"]'); 172 | 173 | var close3 = Close.deserialize(["CLOSE", subscriptionId]); 174 | assert(close3.subscriptionId == subscriptionId); 175 | } 176 | ``` 177 | 178 | ### Any nostr message deserializer 179 | ```dart 180 | import 'package:nostr/nostr.dart'; 181 | 182 | void main() async { 183 | var eventPayload = 184 | '["EVENT","3979053091133091",{"id":"a60679692533b308f1d862c2a5ca5c08a304e5157b1df5cde0ff0454b9920605","pubkey":"7c579328cf9028a4548d5117afa4f8448fb510ca9023f576b7bc90fc5be6ce7e","created_at":1674405882,"kind":1,"tags":[],"content":"GM gm gm! Currently bathing my brain in coffee ☕️ hahaha. How many other nostrinos love coffee? 🤪🤙","sig":"10262aa6a83e0b744cda2097f06f7354357512b82846f6ef23ef7d997136b64815c343b613a0635a27da7e628c96ac2475f66dd72513c1fb8ce6560824eb25b8"}]'; 185 | var event = Message.deserialize(eventPayload); 186 | assert(event.type == "EVENT"); 187 | assert(event.message.id == 188 | "a60679692533b308f1d862c2a5ca5c08a304e5157b1df5cde0ff0454b9920605"); 189 | 190 | String requestPayload = 191 | '["REQ","22055752544101437",{"kinds":[0,1,2,7],"since":1674320733,"limit":450}]'; 192 | var req = Message.deserialize(requestPayload); 193 | assert(req.type == "REQ"); 194 | assert(req.message.filters[0].limit == 450); 195 | 196 | String closePayload = '["CLOSE","anyrandomstring"]'; 197 | var close = Message.deserialize(closePayload); 198 | assert(close.type == "CLOSE"); 199 | assert(close.message.subscriptionId == "anyrandomstring"); 200 | 201 | String noticePayload = 202 | '["NOTICE", "restricted: we can\'t serve DMs to unauthenticated users, does your client implement NIP-42?"]'; 203 | var notice = Message.deserialize(noticePayload); 204 | assert(notice.type == "NOTICE"); 205 | 206 | String eosePayload = '["EOSE", "random"]'; 207 | var eose = Message.deserialize(eosePayload); 208 | assert(eose.type == "EOSE"); 209 | 210 | String okPayload = 211 | '["OK", "b1a649ebe8b435ec71d3784793f3bbf4b93e64e17568a741aecd4c7ddeafce30", true, ""]'; 212 | var ok = Message.deserialize(okPayload); 213 | assert(ok.type == "OK"); 214 | } 215 | ``` 216 | 217 | ### NIP 02 Contact List and Petnames 218 | ```dart 219 | import 'package:nostr/nostr.dart'; 220 | 221 | void main() { 222 | // Decode profiles from an event of kind=3 223 | var event = Event.from( 224 | kind: 3, 225 | tags: [ 226 | ["p", "91cf9..4e5ca", "wss://alicerelay.com/", "alice"], 227 | ["p", "14aeb..8dad4", "wss://bobrelay.com/nostr", "bob"], 228 | ["p", "612ae..e610f", "ws://carolrelay.com/ws", "carol"], 229 | ], 230 | content: "", 231 | privkey: "5ee1c8000ab28edd64d74a7d951ac2dd559814887b1b9e1ac7c5f89e96125c12", 232 | ); 233 | 234 | List someProfiles = Nip2.decode(event); 235 | assert(someProfiles[0].key == "91cf9..4e5ca"); 236 | assert(someProfiles[1].relay == "wss://bobrelay.com/nostr"); 237 | assert(someProfiles[2].petname == "carol"); 238 | 239 | // Instantiate a new nip2 profile 240 | String key = "91cf9..4e5ca"; 241 | String relay = "wss://alicerelay.com/"; 242 | String petname = "alice"; 243 | var alice = Profile(key, relay, petname); 244 | 245 | List profiles = [ 246 | alice, 247 | Profile("21df6d143fb96c2ec9d63726bf9edc71", "", "erin") 248 | ]; 249 | 250 | // Encode profiles to nostr event.tags 251 | List> tags = Nip2.toTags(profiles); 252 | assert(tags[1][0] == "p"); 253 | assert(tags[1][3] == "erin"); 254 | 255 | // Decode event.tags to profiles list 256 | List newProfiles = Nip2.toProfiles([ 257 | ["p", "91cf9..4e5ca", "wss://alicerelay.com/", "alice"], 258 | ["p", "14aeb..8dad4", "wss://bobrelay.com/nostr", "bob"], 259 | ["p", "612ae..e610f", "ws://carolrelay.com/ws", "carol"] 260 | ]); 261 | assert(newProfiles[2].petname == "carol"); 262 | } 263 | 264 | ``` 265 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | # This file configures the static analysis results for your project (errors, 2 | # warnings, and lints). 3 | # 4 | # This enables the 'recommended' set of lints from `package:lints`. 5 | # This set helps identify many issues that may lead to problems when running 6 | # or consuming Dart code, and enforces writing Dart using a single, idiomatic 7 | # style and format. 8 | # 9 | # If you want a smaller set of lints you can change this to specify 10 | # 'package:lints/core.yaml'. These are just the most critical lints 11 | # (the recommended set includes the core lints). 12 | # The core lints are also what is used by pub.dev for scoring packages. 13 | 14 | analyzer: 15 | errors: 16 | avoid_classes_with_only_static_members: ignore 17 | avoid_multiple_declarations_per_line: ignore 18 | avoid_positional_boolean_parameters: ignore 19 | avoid_print: ignore 20 | cascade_invocations: ignore 21 | document_ignores: ignore 22 | no_literal_bool_comparisons: ignore 23 | parameter_assignments: ignore 24 | prefer_expression_function_bodies: ignore 25 | public_member_api_docs: ignore 26 | require_trailing_commas: ignore 27 | sort_constructors_first: ignore 28 | specify_nonobvious_local_variable_types: ignore 29 | use_string_buffers: ignore 30 | include: package:lints/recommended.yaml 31 | 32 | # Uncomment the following section to specify additional rules. 33 | 34 | linter: 35 | rules: 36 | - always_declare_return_types 37 | - always_put_required_named_parameters_first 38 | - always_use_package_imports 39 | - annotate_overrides 40 | - annotate_redeclares 41 | - avoid_annotating_with_dynamic 42 | - avoid_bool_literals_in_conditional_expressions 43 | - avoid_catches_without_on_clauses 44 | - avoid_catching_errors 45 | - avoid_classes_with_only_static_members 46 | - avoid_double_and_int_checks 47 | # - avoid_dynamic_calls 48 | - avoid_empty_else 49 | - avoid_equals_and_hash_code_on_mutable_classes 50 | - avoid_escaping_inner_quotes 51 | - avoid_field_initializers_in_const_classes 52 | - avoid_final_parameters 53 | - avoid_function_literals_in_foreach_calls 54 | - avoid_futureor_void 55 | - avoid_implementing_value_types 56 | - avoid_init_to_null 57 | - avoid_js_rounded_ints 58 | - avoid_multiple_declarations_per_line 59 | - avoid_null_checks_in_equality_operators 60 | - avoid_positional_boolean_parameters 61 | - avoid_print 62 | - avoid_private_typedef_functions 63 | - avoid_redundant_argument_values 64 | - avoid_relative_lib_imports 65 | - avoid_renaming_method_parameters 66 | - avoid_return_types_on_setters 67 | - avoid_returning_null_for_void 68 | - avoid_returning_this 69 | - avoid_setters_without_getters 70 | - avoid_shadowing_type_parameters 71 | - avoid_single_cascade_in_expression_statements 72 | - avoid_slow_async_io 73 | - avoid_type_to_string 74 | - avoid_types_as_parameter_names 75 | - avoid_unnecessary_containers 76 | - avoid_unused_constructor_parameters 77 | - avoid_void_async 78 | - avoid_web_libraries_in_flutter 79 | - await_only_futures 80 | - camel_case_extensions 81 | - camel_case_types 82 | - cancel_subscriptions 83 | - cascade_invocations 84 | - cast_nullable_to_non_nullable 85 | - close_sinks 86 | - collection_methods_unrelated_type 87 | - combinators_ordering 88 | - comment_references 89 | - conditional_uri_does_not_exist 90 | - constant_identifier_names 91 | - control_flow_in_finally 92 | - curly_braces_in_flow_control_structures 93 | - dangling_library_doc_comments 94 | - depend_on_referenced_packages 95 | - deprecated_consistency 96 | - deprecated_member_use_from_same_package 97 | - diagnostic_describe_all_properties 98 | - directives_ordering 99 | - discarded_futures 100 | - do_not_use_environment 101 | - document_ignores 102 | - empty_catches 103 | - empty_constructor_bodies 104 | - empty_statements 105 | - eol_at_end_of_file 106 | - exhaustive_cases 107 | - file_names 108 | - flutter_style_todos 109 | - hash_and_equals 110 | - implementation_imports 111 | - implicit_call_tearoffs 112 | - implicit_reopen 113 | - invalid_case_patterns 114 | - invalid_runtime_check_with_js_interop_types 115 | - join_return_with_assignment 116 | - leading_newlines_in_multiline_strings 117 | - library_annotations 118 | - library_names 119 | - library_prefixes 120 | - library_private_types_in_public_api 121 | # - lines_longer_than_80_chars 122 | - literal_only_boolean_expressions 123 | - matching_super_parameters 124 | - missing_code_block_language_in_doc_comment 125 | - missing_whitespace_between_adjacent_strings 126 | - no_adjacent_strings_in_list 127 | - no_default_cases 128 | - no_duplicate_case_values 129 | - no_leading_underscores_for_library_prefixes 130 | - no_leading_underscores_for_local_identifiers 131 | - no_literal_bool_comparisons 132 | - no_logic_in_create_state 133 | - no_runtimeType_toString 134 | - no_self_assignments 135 | - no_wildcard_variable_uses 136 | - non_constant_identifier_names 137 | - noop_primitive_operations 138 | - null_check_on_nullable_type_parameter 139 | - null_closures 140 | - one_member_abstracts 141 | - only_throw_errors 142 | - overridden_fields 143 | - package_names 144 | - package_prefixed_library_names 145 | - parameter_assignments 146 | - prefer_adjacent_string_concatenation 147 | - prefer_asserts_in_initializer_lists 148 | - prefer_collection_literals 149 | - prefer_conditional_assignment 150 | - prefer_const_constructors 151 | - prefer_const_constructors_in_immutables 152 | - prefer_const_declarations 153 | - prefer_const_literals_to_create_immutables 154 | - prefer_constructors_over_static_methods 155 | - prefer_contains 156 | - prefer_expression_function_bodies 157 | - prefer_final_fields 158 | - prefer_final_in_for_each 159 | - prefer_final_locals 160 | 161 | - prefer_for_elements_to_map_fromIterable 162 | - prefer_foreach 163 | - prefer_function_declarations_over_variables 164 | - prefer_generic_function_type_aliases 165 | - prefer_if_elements_to_conditional_expressions 166 | - prefer_if_null_operators 167 | - prefer_initializing_formals 168 | - prefer_inlined_adds 169 | - prefer_int_literals 170 | - prefer_interpolation_to_compose_strings 171 | - prefer_is_empty 172 | - prefer_is_not_empty 173 | - prefer_is_not_operator 174 | - prefer_iterable_whereType 175 | - prefer_mixin 176 | - prefer_null_aware_method_calls 177 | - prefer_null_aware_operators 178 | - prefer_spread_collections 179 | - prefer_typing_uninitialized_variables 180 | - prefer_void_to_null 181 | - provide_deprecation_message 182 | - public_member_api_docs 183 | - recursive_getters 184 | - require_trailing_commas 185 | - secure_pubspec_urls 186 | - sized_box_for_whitespace 187 | - sized_box_shrink_expand 188 | - slash_for_doc_comments 189 | - sort_child_properties_last 190 | - sort_constructors_first 191 | - sort_pub_dependencies 192 | - sort_unnamed_constructors_first 193 | - specify_nonobvious_local_variable_types 194 | - test_types_in_equals 195 | - throw_in_finally 196 | - tighten_type_of_initializing_formals 197 | - type_annotate_public_apis 198 | - type_init_formals 199 | - type_literal_in_constant_pattern 200 | - unawaited_futures 201 | - unintended_html_in_doc_comment 202 | - unnecessary_await_in_return 203 | - unnecessary_brace_in_string_interps 204 | - unnecessary_breaks 205 | - unnecessary_const 206 | - unnecessary_constructor_name 207 | - unnecessary_getters_setters 208 | - unnecessary_lambdas 209 | - unnecessary_late 210 | - unnecessary_library_directive 211 | - unnecessary_library_name 212 | - unnecessary_new 213 | - unnecessary_null_aware_assignments 214 | - unnecessary_null_aware_operator_on_extension_on_nullable 215 | - unnecessary_null_checks 216 | - unnecessary_null_in_if_null_operators 217 | - unnecessary_nullable_for_final_variable_declarations 218 | - unnecessary_overrides 219 | - unnecessary_parenthesis 220 | - unnecessary_raw_strings 221 | - unnecessary_statements 222 | - unnecessary_string_escapes 223 | - unnecessary_string_interpolations 224 | - unnecessary_this 225 | - unnecessary_to_list_in_spreads 226 | - unreachable_from_main 227 | - unrelated_type_equality_checks 228 | - use_build_context_synchronously 229 | - use_colored_box 230 | - use_decorated_box 231 | - use_enums 232 | - use_full_hex_values_for_flutter_colors 233 | - use_function_type_syntax_for_parameters 234 | - use_if_null_to_convert_nulls_to_bools 235 | - use_is_even_rather_than_modulo 236 | - use_key_in_widget_constructors 237 | - use_late_for_private_fields_and_variables 238 | - use_named_constants 239 | - use_raw_strings 240 | - use_rethrow_when_possible 241 | - use_setters_to_change_properties 242 | - use_string_buffers 243 | - use_string_in_part_of_directives 244 | - use_super_parameters 245 | - use_test_throws_matchers 246 | - use_to_and_as_if_applicable 247 | - use_truncating_division 248 | - valid_regexps 249 | - void_checks 250 | 251 | # analyzer: 252 | # exclude: 253 | # - path/to/excluded/files/** 254 | 255 | # For more information about the core and recommended set of lints, see 256 | # https://dart.dev/go/core-lints 257 | 258 | # For additional information about configuring this file, see 259 | # https://dart.dev/guides/language/analysis-options 260 | -------------------------------------------------------------------------------- /example/close_example.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:nostr/nostr.dart'; 4 | 5 | void main() async { 6 | final String subscriptionId = generate64RandomHexChars(); 7 | final close1 = Close(subscriptionId); 8 | assert(close1.subscriptionId == subscriptionId); 9 | 10 | final close2 = Close(subscriptionId); 11 | assert(close2.serialize() == '["CLOSE","$subscriptionId"]'); 12 | 13 | final close3 = Close.deserialize(json.encode(["CLOSE", subscriptionId])); 14 | assert(close3.subscriptionId == subscriptionId); 15 | } 16 | -------------------------------------------------------------------------------- /example/message_example.dart: -------------------------------------------------------------------------------- 1 | import 'package:nostr/nostr.dart'; 2 | 3 | void main() async { 4 | const eventPayload = 5 | '["EVENT","5ce1758166673a70e391303fb7cfeb0f5d47ec38a9342a27858950d13424d59b",{"content":"No quotes from the Bible? My global feed is full of religious nutcases","created_at":1685026912,"id":"e695c81fa5099b9f3ef0d868d8143eae481954114681bbe4432b50e44e199927","kind":1,"pubkey":"ab4103fc8cd4e1d8d31a99d079ed8293bdc26b11ec1ec61d95c13e43d7e048ff","sig":"0d17d6197ad12ab5ad77eb51231ae12c2ce1e639218bb6e3a01cce78aa092f3e77fb1f914b690675a425dcfd5b4dfa7be72c2cb608568798361781d75e354b32","tags":[["e","7804acd35bb9727d0374545a99bb4f30f901289aebaf3cf330dda28c235cd7ad"],["p","1bc70a0148b3f316da33fe3c89f23e3e71ac4ff998027ec712b905cd24f6a411"]]}]'; 6 | final event = Message.deserialize(eventPayload); 7 | assert(event.type == "EVENT"); 8 | assert(event.message.id == 9 | "a60679692533b308f1d862c2a5ca5c08a304e5157b1df5cde0ff0454b9920605"); 10 | 11 | const String requestPayload = 12 | '["REQ","22055752544101437",{"kinds":[0,1,2,7],"since":1674320733,"limit":450}]'; 13 | final req = Message.deserialize(requestPayload); 14 | assert(req.type == "REQ"); 15 | assert(req.message.filters[0].limit == 450); 16 | 17 | const String closePayload = '["CLOSE","anyrandomstring"]'; 18 | final close = Message.deserialize(closePayload); 19 | assert(close.type == "CLOSE"); 20 | assert(close.message.subscriptionId == "anyrandomstring"); 21 | 22 | const String noticePayload = 23 | '["NOTICE", "restricted: we can\'t serve DMs to unauthenticated users, does your client implement NIP-42?"]'; 24 | final notice = Message.deserialize(noticePayload); 25 | assert(notice.type == "NOTICE"); 26 | 27 | const String eosePayload = '["EOSE", "random"]'; 28 | final eose = Message.deserialize(eosePayload); 29 | assert(eose.type == "EOSE"); 30 | 31 | const String okPayload = 32 | '["OK", "b1a649ebe8b435ec71d3784793f3bbf4b93e64e17568a741aecd4c7ddeafce30", true, ""]'; 33 | final ok = Message.deserialize(okPayload); 34 | assert(ok.type == "OK"); 35 | } 36 | -------------------------------------------------------------------------------- /example/nip_002_example.dart: -------------------------------------------------------------------------------- 1 | import 'package:nostr/nostr.dart'; 2 | 3 | void main() { 4 | // Decode profiles from an event of kind=3 5 | final event = Event.from( 6 | kind: 3, 7 | tags: [ 8 | ["p", "91cf9..4e5ca", "wss://alicerelay.com/", "alice"], 9 | ["p", "14aeb..8dad4", "wss://bobrelay.com/nostr", "bob"], 10 | ["p", "612ae..e610f", "ws://carolrelay.com/ws", "carol"], 11 | ], 12 | content: "", 13 | privkey: "5ee1c8000ab28edd64d74a7d951ac2dd559814887b1b9e1ac7c5f89e96125c12", 14 | ); 15 | 16 | final someProfiles = Nip2.decode(event); 17 | assert(someProfiles[0].key == "91cf9..4e5ca"); 18 | assert(someProfiles[1].relay == "wss://bobrelay.com/nostr"); 19 | assert(someProfiles[2].petname == "carol"); 20 | 21 | // Instantiate a new nip2 profile 22 | const key = "91cf9..4e5ca"; 23 | const relay = "wss://alicerelay.com/"; 24 | const petname = "alice"; 25 | final alice = Profile(key, relay, petname); 26 | 27 | final List profiles = [ 28 | alice, 29 | Profile("21df6d143fb96c2ec9d63726bf9edc71", "", "erin") 30 | ]; 31 | 32 | // Encode profiles to nostr event.tags 33 | final List> tags = Nip2.toTags(profiles); 34 | assert(tags[1][0] == "p"); 35 | assert(tags[1][3] == "erin"); 36 | 37 | // Decode event.tags to profiles list 38 | final List newProfiles = Nip2.toProfiles([ 39 | ["p", "91cf9..4e5ca", "wss://alicerelay.com/", "alice"], 40 | ["p", "14aeb..8dad4", "wss://bobrelay.com/nostr", "bob"], 41 | ["p", "612ae..e610f", "ws://carolrelay.com/ws", "carol"] 42 | ]); 43 | assert(newProfiles[2].petname == "carol"); 44 | } 45 | -------------------------------------------------------------------------------- /example/nostr_example.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | import 'package:nostr/nostr.dart'; 3 | 4 | void main() async { 5 | // Use the Keys class to manipulate secret/public keys and use handy methods encapsulated from dart-bip340 6 | final keys = Keys( 7 | "5ee1c8000ab28edd64d74a7d951ac2dd559814887b1b9e1ac7c5f89e96125c12", 8 | ); 9 | assert(keys.public == 10 | "981cc2078af05b62ee1f98cff325aac755bf5c5836a265c254447b5933c6223b"); 11 | 12 | // or generate random keys 13 | final randomKeys = Keys.generate(); 14 | print(randomKeys.secret); 15 | 16 | // Instantiate an event with all the field 17 | const id = "4b697394206581b03ca5222b37449a9cdca1741b122d78defc177444e2536f49"; 18 | final pubkey = keys.public; 19 | const createdAt = 1672175320; 20 | const kind = 1; 21 | final tags = [[]]; 22 | const content = "Ceci est une analyse du websocket"; 23 | const sig = 24 | "797c47bef50eff748b8af0f38edcb390facf664b2367d72eb71c50b5f37bc83c4ae9cc9007e8489f5f63c66a66e101fd1515d0a846385953f5f837efb9afe885"; 25 | 26 | final oneEvent = Event( 27 | id, 28 | pubkey, 29 | createdAt, 30 | kind, 31 | tags, 32 | content, 33 | sig, 34 | ); 35 | assert(oneEvent.id == 36 | "4b697394206581b03ca5222b37449a9cdca1741b122d78defc177444e2536f49"); 37 | 38 | // Create a partial event from nothing and fill it with data until it is valid 39 | final partialEvent = Event.partial(); 40 | assert(partialEvent.isValid() == false); 41 | partialEvent 42 | ..createdAt = currentUnixTimestampSeconds() 43 | ..pubkey = 44 | "981cc2078af05b62ee1f98cff325aac755bf5c5836a265c254447b5933c6223b" 45 | ..id = partialEvent.getEventId() 46 | ..sig = partialEvent.getSignature( 47 | "5ee1c8000ab28edd64d74a7d951ac2dd559814887b1b9e1ac7c5f89e96125c12", 48 | ); 49 | assert(partialEvent.isValid() == true); 50 | 51 | // Instantiate an event with a partial data and let the library sign the event with your secret key 52 | final Event anotherEvent = Event.from( 53 | kind: 1, 54 | tags: [], 55 | content: "vi veri universum vivus vici", 56 | privkey: 57 | "5ee1c8000ab28edd64d74a7d951ac2dd559814887b1b9e1ac7c5f89e96125c12", // DO NOT REUSE THIS PRIVATE KEY 58 | ); 59 | 60 | assert(anotherEvent.pubkey == 61 | "981cc2078af05b62ee1f98cff325aac755bf5c5836a265c254447b5933c6223b"); 62 | 63 | // Connecting to a nostr relay using websocket 64 | final WebSocket webSocket = await WebSocket.connect( 65 | 'wss://relay.nostr.info', // or any nostr relay 66 | ); 67 | // if the current socket fail try another one 68 | // wss://nostr.sandwich.farm 69 | // wss://relay.damus.io 70 | 71 | // Send an event to the WebSocket server 72 | webSocket.add(anotherEvent.serialize()); 73 | 74 | // Listen for events from the WebSocket server 75 | await Future.delayed(const Duration(seconds: 1)); 76 | webSocket.listen((event) { 77 | print('Received event: $event'); 78 | }); 79 | 80 | // Close the WebSocket connection 81 | await webSocket.close(); 82 | } 83 | -------------------------------------------------------------------------------- /example/request_filters_example.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | import 'package:nostr/nostr.dart'; 3 | 4 | void main() async { 5 | // Create a subscription message request with one or many filters 6 | final requestWithFilter = 7 | Request(subscriptionId: generate64RandomHexChars(), filters: [ 8 | Filter( 9 | kinds: [0, 1, 2, 7], 10 | since: 1674063680, 11 | limit: 450, 12 | ) 13 | ]); 14 | 15 | // Connecting to a nostr relay using websocket 16 | final webSocket = await WebSocket.connect( 17 | 'wss://relay.nostr.info', // or any nostr relay 18 | ); 19 | // if the current socket fail try another one 20 | // wss://nostr.sandwich.farm 21 | // wss://relay.damus.io 22 | 23 | // Send a request message to the WebSocket server 24 | webSocket.add(requestWithFilter.serialize()); 25 | 26 | // Listen for events from the WebSocket server 27 | await Future.delayed(const Duration(seconds: 1)); 28 | webSocket.listen((event) { 29 | print('Received event: $event'); 30 | }); 31 | 32 | // Close the WebSocket connection 33 | await webSocket.close(); 34 | } 35 | -------------------------------------------------------------------------------- /lib/nostr.dart: -------------------------------------------------------------------------------- 1 | /// Support for doing something awesome. 2 | /// 3 | /// More dartdocs go here. 4 | library; 5 | 6 | export 'src/close.dart'; 7 | export 'src/eose.dart'; 8 | export 'src/event.dart'; 9 | export 'src/filter.dart'; 10 | export 'src/keys.dart'; 11 | export 'src/message.dart'; 12 | export 'src/nips/nip_001.dart'; 13 | export 'src/nips/nip_002.dart'; 14 | export 'src/nips/nip_004.dart'; 15 | export 'src/nips/nip_005.dart'; 16 | export 'src/nips/nip_009.dart'; 17 | export 'src/nips/nip_010.dart'; 18 | export 'src/nips/nip_013.dart'; 19 | export 'src/nips/nip_017.dart'; 20 | export 'src/nips/nip_019.dart'; 21 | export 'src/nips/nip_020.dart'; 22 | export 'src/nips/nip_021.dart'; 23 | export 'src/nips/nip_023.dart'; 24 | export 'src/nips/nip_028.dart'; 25 | export 'src/nips/nip_044.dart'; 26 | export 'src/nips/nip_044_utils.dart'; 27 | export 'src/nips/nip_051.dart'; 28 | export 'src/nips/nip_059.dart'; 29 | export 'src/request.dart'; 30 | export 'src/schnorr.dart'; 31 | export 'src/utils.dart'; 32 | -------------------------------------------------------------------------------- /lib/src/close.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | /// Used to stop previous subscriptions. 4 | class Close { 5 | /// subscription_id is a random string that should be used to represent a subscription. 6 | late String subscriptionId; 7 | 8 | /// Default constructor 9 | Close(this.subscriptionId); 10 | 11 | /// Serialize to nostr close message 12 | /// - ["CLOSE", subscription_id] 13 | String serialize() => json.encode(["CLOSE", subscriptionId]); 14 | 15 | /// Deserialize a nostr close message 16 | /// - ["CLOSE", subscription_id] 17 | Close.deserialize(String payload) { 18 | final data = json.decode(payload); 19 | if (data.length != 2) throw Exception('Invalid length for CLOSE message'); 20 | subscriptionId = data[1]; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /lib/src/crypto/kepler.dart: -------------------------------------------------------------------------------- 1 | // credit: https://github.com/tjcampanella/kepler/blob/master/lib/src/kepler.dart 2 | 3 | import 'dart:typed_data'; 4 | 5 | import 'package:convert/convert.dart'; 6 | import 'package:nostr/src/crypto/operator.dart'; 7 | import 'package:pointycastle/export.dart'; 8 | 9 | class Kepler { 10 | /// return a Bytes data secret 11 | static List> byteSecret(String privateString, String publicString) { 12 | final secret = rawSecret(privateString, publicString); 13 | assert(secret.x != null && secret.y != null); 14 | final xs = secret.x!.toBigInteger()!.toRadixString(16); 15 | final ys = secret.y!.toBigInteger()!.toRadixString(16); 16 | final hexX = leftPadding(xs, 64); 17 | final hexY = leftPadding(ys, 64); 18 | final secretBytes = Uint8List.fromList(hex.decode('$hexX$hexY')); 19 | return [secretBytes.sublist(0, 32), secretBytes.sublist(32, 40)]; 20 | } 21 | 22 | /// return a ECPoint data secret 23 | static ECPoint rawSecret(String privateString, String publicString) { 24 | final privateKey = loadPrivateKey(privateString); 25 | final publicKey = loadPublicKey(publicString); 26 | assert(privateKey.d != null && publicKey.Q != null); 27 | return scalarMultiple(privateKey.d!, publicKey.Q!); 28 | } 29 | 30 | static String leftPadding(String s, int width) { 31 | const paddingData = '000000000000000'; 32 | final paddingWidth = width - s.length; 33 | if (paddingWidth < 1) { 34 | return s; 35 | } 36 | return "${paddingData.substring(0, paddingWidth)}$s"; 37 | } 38 | 39 | /// return a privateKey from hex string 40 | static ECPrivateKey loadPrivateKey(String storedkey) { 41 | final d = BigInt.parse(storedkey, radix: 16); 42 | final param = ECCurve_secp256k1(); 43 | return ECPrivateKey(d, param); 44 | } 45 | 46 | /// return a publicKey from hex string 47 | static ECPublicKey loadPublicKey(String storedkey) { 48 | final param = ECCurve_secp256k1(); 49 | if (storedkey.length < 120) { 50 | final codeList = []; 51 | for (var idx = 0; idx < storedkey.length - 1; idx += 2) { 52 | final hexStr = storedkey.substring(idx, idx + 2); 53 | codeList.add(int.parse(hexStr, radix: 16)); 54 | } 55 | final Q = param.curve.decodePoint(codeList); 56 | return ECPublicKey(Q, param); 57 | } else { 58 | final x = BigInt.parse(storedkey.substring(0, 64), radix: 16); 59 | final y = BigInt.parse(storedkey.substring(64), radix: 16); 60 | final Q = param.curve.createPoint(x, y); 61 | return ECPublicKey(Q, param); 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /lib/src/crypto/nip_004.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'dart:typed_data'; 3 | import 'package:nostr/nostr.dart'; 4 | import 'package:nostr/src/crypto/kepler.dart'; 5 | import 'package:pointycastle/export.dart'; 6 | 7 | /// NIP4 cipher 8 | String nip4cipher( 9 | String privkey, 10 | String pubkey, 11 | String payload, { 12 | required bool cipher, 13 | String? nonce, 14 | }) { 15 | // if cipher=false –> decipher –> nonce needed 16 | if (!cipher && nonce == null) throw Exception("missing nonce"); 17 | 18 | // init variables 19 | Uint8List input, output, iv; 20 | if (!cipher && nonce != null) { 21 | input = base64.decode(payload); 22 | output = Uint8List(input.length); 23 | iv = base64.decode(nonce); 24 | } else { 25 | input = const Utf8Encoder().convert(payload); 26 | output = Uint8List(input.length + 16); 27 | iv = Uint8List.fromList(generateRandomBytes(16)); 28 | } 29 | 30 | // params 31 | final keplerSecret = Kepler.byteSecret(privkey, pubkey); 32 | final key = Uint8List.fromList(keplerSecret[0]); 33 | final params = PaddedBlockCipherParameters( 34 | ParametersWithIV(KeyParameter(key), iv), 35 | null, 36 | ); 37 | final algo = PaddedBlockCipherImpl( 38 | PKCS7Padding(), 39 | CBCBlockCipher(AESEngine()), 40 | )..init(cipher, params); 41 | 42 | // processing 43 | var offset = 0; 44 | while (offset < input.length - 16) { 45 | offset += algo.processBlock(input, offset, output, offset); 46 | } 47 | offset += algo.doFinal(input, offset, output, offset); 48 | final Uint8List result = output.sublist(0, offset); 49 | 50 | if (cipher) { 51 | final String stringIv = base64.encode(iv); 52 | final String plaintext = base64.encode(result); 53 | return "$plaintext?iv=$stringIv"; 54 | } else { 55 | return const Utf8Decoder().convert(result); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /lib/src/crypto/operator.dart: -------------------------------------------------------------------------------- 1 | // credit: https://github.com/tjcampanella/kepler/blob/master/lib/src/operator.dart 2 | 3 | import 'package:pointycastle/export.dart'; 4 | 5 | BigInt theP = BigInt.parse( 6 | "fffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2f", 7 | radix: 16); 8 | BigInt theN = BigInt.parse( 9 | "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141", 10 | radix: 16); 11 | 12 | bool isOnCurve(ECPoint point) { 13 | assert(point.x != null && 14 | point.y != null && 15 | point.curve.a != null && 16 | point.curve.b != null); 17 | final x = point.x!.toBigInteger(); 18 | final y = point.y!.toBigInteger(); 19 | final rs = (y! * y - 20 | x! * x * x - 21 | point.curve.a!.toBigInteger()! * x - 22 | point.curve.b!.toBigInteger()!) % 23 | theP; 24 | return rs == BigInt.from(0); 25 | } 26 | 27 | BigInt inverseMod(BigInt k, BigInt p) { 28 | if (k.compareTo(BigInt.zero) == 0) { 29 | throw Exception("Cannot Divide By 0"); 30 | } 31 | if (k < BigInt.from(0)) { 32 | return p - inverseMod(-k, p); 33 | } 34 | final s = [BigInt.from(0), BigInt.from(1), BigInt.from(1)]; 35 | final t = [BigInt.from(1), BigInt.from(0), BigInt.from(0)]; 36 | final r = [p, k, k]; 37 | while (r[0] != BigInt.from(0)) { 38 | final quotient = r[2] ~/ r[0]; 39 | r[1] = r[2] - quotient * r[0]; 40 | r[2] = r[0]; 41 | r[0] = r[1]; 42 | s[1] = s[2] - quotient * s[0]; 43 | s[2] = s[0]; 44 | s[0] = s[1]; 45 | t[1] = t[2] - quotient * t[0]; 46 | t[2] = t[0]; 47 | t[0] = t[1]; 48 | } 49 | final gcd = r[2]; 50 | final x = s[2]; 51 | // final y = t[2]; 52 | assert(gcd == BigInt.from(1)); 53 | assert((k * x) % p == BigInt.from(1)); 54 | return x % p; 55 | } 56 | 57 | ECPoint pointNeg(ECPoint point) { 58 | assert(isOnCurve(point)); 59 | assert(point.x != null || point.y != null); 60 | final x = point.x!.toBigInteger(); 61 | final y = point.y!.toBigInteger(); 62 | final result = point.curve.createPoint(x!, -y! % theP); 63 | assert(isOnCurve(result)); 64 | return result; 65 | } 66 | 67 | ECPoint pointAdd(ECPoint? point1, ECPoint? point2) { 68 | if (point1 == null) { 69 | return point2!; 70 | } 71 | if (point2 == null) { 72 | return point1; 73 | } 74 | assert(isOnCurve(point1)); 75 | assert(isOnCurve(point2)); 76 | final x1 = point1.x!.toBigInteger(); 77 | final y1 = point1.y!.toBigInteger(); 78 | final x2 = point2.x!.toBigInteger(); 79 | final y2 = point2.y!.toBigInteger(); 80 | 81 | BigInt m; 82 | if (x1 == x2) { 83 | m = (BigInt.from(3) * x1! * x1 + point1.curve.a!.toBigInteger()!) * 84 | inverseMod(BigInt.from(2) * y1!, theP); 85 | } else { 86 | m = (y1! - y2!) * inverseMod(x1! - x2!, theP); 87 | } 88 | final x3 = m * m - x1 - x2!; 89 | final y3 = y1 + m * (x3 - x1); 90 | final ECPoint result = point1.curve.createPoint(x3 % theP, -y3 % theP); 91 | assert(isOnCurve(result)); 92 | return result; 93 | } 94 | 95 | ECPoint scalarMultiple(BigInt k, ECPoint point) { 96 | assert(isOnCurve(point)); 97 | assert((k % theN).compareTo(BigInt.zero) != 0); 98 | assert(point.x != null && point.y != null); 99 | if (k < BigInt.from(0)) { 100 | return scalarMultiple(-k, pointNeg(point)); 101 | } 102 | ECPoint? result; 103 | ECPoint addend = point; 104 | while (k > BigInt.from(0)) { 105 | if (k & BigInt.from(1) > BigInt.from(0)) { 106 | result = pointAdd(result, addend); 107 | } 108 | addend = pointAdd(addend, addend); 109 | k >>= 1; 110 | } 111 | assert(isOnCurve(result!)); 112 | return result!; 113 | } 114 | -------------------------------------------------------------------------------- /lib/src/eose.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | /// Indicates "end of stored events" 4 | /// 5 | /// this class is mostly for getting subscription id 6 | class Eose { 7 | /// default constructor 8 | Eose(this.subscriptionId); 9 | 10 | /// subscription_id is a random string that should be used to represent a subscription. 11 | final String subscriptionId; 12 | 13 | /// Serialize to nostr close message 14 | /// - ["EOSE", subscription_id] 15 | String serialize() { 16 | return json.encode(["EOSE", subscriptionId]); 17 | } 18 | 19 | /// Deserialize a nostr close message 20 | /// - ["CLOSE", subscription_id] 21 | factory Eose.deserialize(String payload) { 22 | final data = json.decode(payload); 23 | if (data is! List) { 24 | throw Exception('Invalid type for EOSE message'); 25 | } 26 | if (data.length != 2) throw Exception('Invalid length for EOSE message'); 27 | return Eose(data[1]); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /lib/src/event.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:bip340/bip340.dart' as bip340; 4 | import 'package:convert/convert.dart'; 5 | import 'package:nostr/src/utils.dart'; 6 | 7 | /// The only object type that exists is the event, which has the following format on the wire: 8 | /// 9 | /// - "id": "32-bytes hex-encoded sha256 of the the serialized event data" 10 | /// - "pubkey": "32-bytes hex-encoded public key of the event creator", 11 | /// - "created_at": unix timestamp in seconds, 12 | /// - "kind": integer, 13 | /// - "tags": [ 14 | /// ["e", "32-bytes hex of the id of another event", "recommended relay URL"], 15 | /// ["p", "32-bytes hex of the key", "recommended relay URL"] 16 | /// ], 17 | /// - "content": "arbitrary string", 18 | /// - "sig": "64-bytes signature of the sha256 hash of the serialized event data, which is the same as the 'id' field" 19 | class Event { 20 | /// 32-bytes hex-encoded sha256 of the the serialized event data (hex) 21 | late String id; 22 | 23 | /// 32-bytes hex-encoded public key of the event creator (hex) 24 | late String pubkey; 25 | 26 | /// unix timestamp in seconds 27 | late int createdAt; 28 | 29 | /// - 0: set_metadata: the content is set to a stringified JSON object {name: username, about: string, picture: url} describing the user who created the event. A relay may delete past set_metadata events once it gets a new one for the same pubkey. 30 | /// - 1: text_note: the content is set to the text content of a note (anything the user wants to say). Non-plaintext notes should instead use kind 1000-10000 as described in NIP-16. 31 | /// - 2: recommend_server: the content is set to the URL (e.g., wss://somerelay.com) of a relay the event creator wants to recommend to its followers. 32 | late int kind; 33 | 34 | /// The tags array can store a tag identifier as the first element of each subarray, plus arbitrary information afterward (always as strings). 35 | /// 36 | /// This NIP defines "p" — meaning "pubkey", which points to a pubkey of someone that is referred to in the event —, and "e" — meaning "event", which points to the id of an event this event is quoting, replying to or referring to somehow. 37 | late List> tags; 38 | 39 | /// arbitrary string 40 | String content = ""; 41 | 42 | /// 64-bytes signature of the sha256 hash of the serialized event data, which is the same as the "id" field 43 | late String sig; 44 | 45 | /// subscription_id is a random string that should be used to represent a subscription. 46 | String? subscriptionId; 47 | 48 | /// Default constructor 49 | /// 50 | /// verify: ensure your event isValid() –> id, signature, timestamp… 51 | /// 52 | ///```dart 53 | /// String id = 54 | /// "4b697394206581b03ca5222b37449a9cdca1741b122d78defc177444e2536f49"; 55 | /// String pubKey = 56 | /// "981cc2078af05b62ee1f98cff325aac755bf5c5836a265c254447b5933c6223b"; 57 | /// int createdAt = 1672175320; 58 | /// int kind = 1; 59 | /// List> tags = []; 60 | /// String content = "Ceci est une analyse du websocket"; 61 | /// String sig = 62 | /// "797c47bef50eff748b8af0f38edcb390facf664b2367d72eb71c50b5f37bc83c4ae9cc9007e8489f5f63c66a66e101fd1515d0a846385953f5f837efb9afe885"; 63 | /// 64 | /// Event event = Event( 65 | /// id, 66 | /// pubKey, 67 | /// createdAt, 68 | /// kind, 69 | /// tags, 70 | /// content, 71 | /// sig, 72 | /// verify: true, 73 | /// subscriptionId: null, 74 | /// ); 75 | ///``` 76 | Event( 77 | this.id, 78 | this.pubkey, 79 | this.createdAt, 80 | this.kind, 81 | this.tags, 82 | this.content, 83 | this.sig, { 84 | this.subscriptionId, 85 | bool verify = true, 86 | }) { 87 | pubkey = pubkey.toLowerCase(); 88 | if (verify && isValid() == false) throw Exception('Invalid event'); 89 | } 90 | 91 | /// Partial constructor, you have to fill the fields yourself 92 | /// 93 | /// verify: ensure your event isValid() –> id, signature, timestamp… 94 | /// 95 | /// ```dart 96 | /// var partialEvent = Event.partial(); 97 | /// assert(partialEvent.isValid() == false); 98 | /// partialEvent.createdAt = currentUnixTimestampSeconds(); 99 | /// partialEvent.pubkey = 100 | /// "981cc2078af05b62ee1f98cff325aac755bf5c5836a265c254447b5933c6223b"; 101 | /// partialEvent.id = partialEvent.getEventId(); 102 | /// partialEvent.sig = partialEvent.getSignature( 103 | /// "5ee1c8000ab28edd64d74a7d951ac2dd559814887b1b9e1ac7c5f89e96125c12", 104 | /// ); 105 | /// assert(partialEvent.isValid() == true); 106 | /// ``` 107 | factory Event.partial({ 108 | String id = "", 109 | String pubkey = "", 110 | int createdAt = 0, 111 | int kind = 1, 112 | List> tags = const >[], 113 | String content = "", 114 | String sig = "", 115 | String? subscriptionId, 116 | bool verify = false, 117 | }) { 118 | return Event( 119 | id, 120 | pubkey, 121 | createdAt, 122 | kind, 123 | tags, 124 | content, 125 | sig, 126 | verify: verify, 127 | subscriptionId: subscriptionId, 128 | ); 129 | } 130 | 131 | /// Instantiate Event object from the minimum needed data 132 | /// 133 | /// ```dart 134 | ///Event event = Event.from( 135 | /// kind: 1, 136 | /// content: "", 137 | /// privkey: 138 | /// "5ee1c8000ab28edd64d74a7d951ac2dd559814887b1b9e1ac7c5f89e96125c12", 139 | ///); 140 | ///``` 141 | factory Event.from({ 142 | required int kind, 143 | required String content, 144 | required String privkey, 145 | int? createdAt, 146 | List> tags = const [], 147 | String? pubkey, 148 | String? subscriptionId, 149 | bool verify = false, 150 | }) { 151 | createdAt ??= currentUnixTimestampSeconds(); 152 | pubkey ??= bip340.getPublicKey(privkey).toLowerCase(); 153 | 154 | final id = _processEventId(pubkey, createdAt, kind, tags, content); 155 | 156 | final sig = _processSignature(privkey, id); 157 | 158 | return Event( 159 | id, 160 | pubkey, 161 | createdAt, 162 | kind, 163 | tags, 164 | content, 165 | sig, 166 | subscriptionId: subscriptionId, 167 | verify: verify, 168 | ); 169 | } 170 | 171 | /// Deserialize an event from a JSON 172 | /// 173 | /// `verify`: ensure the signature is valid, `true` by default. 174 | /// 175 | /// `verify` to `false` deserialize faster. 176 | /// 177 | /// Throws an [Exception] if any required field is missing. 178 | factory Event.fromMap(Map map, {bool verify = true}) { 179 | final id = getRequiredField(map, 'id'); 180 | final sig = getRequiredField(map, 'sig'); 181 | final pubkey = getRequiredField(map, 'pubkey'); 182 | final createdAt = getRequiredField(map, 'created_at'); 183 | final kind = getRequiredField(map, 'kind'); 184 | final content = getRequiredField(map, 'content'); 185 | final rawTags = getRequiredField(map, 'tags'); 186 | 187 | var tags = [[]]; 188 | try { 189 | tags = rawTags 190 | .map((e) => (e as List).map((e) => e as String).toList()) 191 | .toList(); 192 | } catch (e) { 193 | throw Exception("Invalid 'tags' format: $e"); 194 | } 195 | 196 | return Event( 197 | id, 198 | pubkey, 199 | createdAt, 200 | kind, 201 | tags, 202 | content, 203 | sig, 204 | verify: verify, 205 | ); 206 | } 207 | 208 | factory Event.fromJson(String payload, {bool verify = true}) => 209 | Event.fromMap(json.decode(payload), verify: verify); 210 | 211 | /// Serialize an event as map 212 | Map toMap() => { 213 | 'id': id, 214 | 'pubkey': pubkey, 215 | 'created_at': createdAt, 216 | 'kind': kind, 217 | 'tags': tags, 218 | 'content': content, 219 | 'sig': sig 220 | }; 221 | 222 | String toJson() => json.encode(toMap()); 223 | 224 | /// Serialize to nostr event message 225 | /// - ["EVENT", event JSON as defined above] 226 | /// - ["EVENT", subscription_id, event JSON as defined above] 227 | String serialize() { 228 | return json.encode([ 229 | "EVENT", 230 | if (subscriptionId != null) subscriptionId, 231 | toMap(), 232 | ]); 233 | } 234 | 235 | /// Deserialize a nostr event message 236 | /// - ["EVENT", event JSON as defined above] 237 | /// - ["EVENT", subscription_id, event JSON as defined above] 238 | /// ```dart 239 | /// Event event = Event.deserialize([ 240 | /// "EVENT", 241 | /// { 242 | /// "id": "67bd60e47d7fdddadebff890143167bcd7b5d28b2c3008eae40e0ac5ba0e6b34", 243 | /// "kind": 1, 244 | /// "pubkey": 245 | /// "36685fa5106b1bc03ae7bea82eded855d8f56c41db4c8bdef8099e1e0f2b2afa", 246 | /// "created_at": 1674403511, 247 | /// "content": 248 | /// "Block 773103 was just confirmed. The total value of all the non-coinbase outputs was 61,549,183,849 sats, or \$14,025,828", 249 | /// "tags": [], 250 | /// "sig": 251 | /// "4912a6850a711a876fd2443771f69e094041f7e832df65646a75c2c77989480cce9b41aa5ea3d055c16fe5beb7d11d3d5fa29b4c4046c150b09393c4d3d16eb4" 252 | /// } 253 | /// ]); 254 | /// ``` 255 | factory Event.deserialize(String input, {bool verify = true}) { 256 | final data = json.decode(input); 257 | Map event; 258 | String? subscriptionId; 259 | if (data.length == 2) { 260 | event = data[1] as Map; 261 | } else if (data.length == 3) { 262 | event = data[2] as Map; 263 | subscriptionId = data[1] as String; 264 | } else { 265 | throw Exception('invalid payload'); 266 | } 267 | 268 | final List> tags = (event['tags'] as List) 269 | .map((e) => (e as List).map((e) => e as String).toList()) 270 | .toList(); 271 | 272 | return Event( 273 | event['id'], 274 | event['pubkey'], 275 | event['created_at'], 276 | event['kind'], 277 | tags, 278 | event['content'], 279 | event['sig'], 280 | subscriptionId: subscriptionId, 281 | verify: verify, 282 | ); 283 | } 284 | 285 | /// To obtain the event.id, we sha256 the serialized event. 286 | /// The serialization is done over the UTF-8 JSON-serialized string (with no white space or line breaks) of the following structure: 287 | /// 288 | ///[ 289 | /// 0, 290 | /// `pubkey`, as a (lowercase) hex string, 291 | /// `created_at`, as a number, 292 | /// `kind`, as a number, 293 | /// `tags`, as an array of arrays of non-null strings, 294 | /// `content`, as a strin> 295 | ///] 296 | String getEventId() { 297 | // Included for minimum breaking changes 298 | return _processEventId( 299 | pubkey, 300 | createdAt, 301 | kind, 302 | tags, 303 | content, 304 | ); 305 | } 306 | 307 | // Support for [getEventId] 308 | static String _processEventId( 309 | String pubkey, 310 | int createdAt, 311 | int kind, 312 | List> tags, 313 | String content, 314 | ) { 315 | final data = [0, pubkey.toLowerCase(), createdAt, kind, tags, content]; 316 | final serializedEvent = json.encode(data); 317 | final hash = sha256(utf8.encode(serializedEvent)); 318 | return hex.encode(hash); 319 | } 320 | 321 | /// Each user has a keypair. Signatures, public key, and encodings are done according to the Schnorr signatures standard for the curve secp256k1 322 | /// 64-bytes signature of the sha256 hash of the serialized event data, which is the same as the "id" field 323 | String getSignature(String secretKey) => _processSignature(secretKey, id); 324 | 325 | // Support for [getSignature] 326 | static String _processSignature(String secretKey, String id) { 327 | /// aux must be 32-bytes random bytes, generated at signature time. 328 | /// https://github.com/nbd-wtf/dart-bip340/blob/master/lib/src/bip340.dart#L10 329 | final String aux = generate64RandomHexChars(); 330 | return bip340.sign(secretKey, id, aux); 331 | } 332 | 333 | /// Verify if event checks such as id, signature, non-futuristic are valid 334 | /// Performances could be a reason to disable event checks 335 | bool isValid() { 336 | final String verifyId = getEventId(); 337 | if (createdAt.toString().length == 10 && 338 | id == verifyId && 339 | bip340.verify(pubkey, id, sig)) { 340 | return true; 341 | } else { 342 | return false; 343 | } 344 | } 345 | } 346 | -------------------------------------------------------------------------------- /lib/src/filter.dart: -------------------------------------------------------------------------------- 1 | /// filter is a JSON object that determines what events will be sent in that subscription 2 | class Filter { 3 | /// a list of event ids or prefixes 4 | List? ids; 5 | 6 | /// a list of pubkeys or prefixes, the pubkey of an event must be one of these 7 | List? authors; 8 | 9 | /// a list of a kind numbers 10 | List? kinds; 11 | 12 | /// a list of event ids that are referenced in an "e" tag 13 | List? e; 14 | 15 | /// a list of event ids that are referenced in an "a" tag 16 | List? a; 17 | 18 | /// a list of pubkeys that are referenced in a "p" tag 19 | List? p; 20 | 21 | /// a timestamp, events must be newer than this to pass 22 | int? since; 23 | 24 | /// a timestamp, events must be older than this to pass 25 | int? until; 26 | 27 | /// maximum number of events to be returned in the initial query 28 | int? limit; 29 | 30 | /// nip-50 search term 31 | String? search; 32 | 33 | /// Default constructor 34 | Filter( 35 | {this.ids, 36 | this.authors, 37 | this.kinds, 38 | this.e, 39 | this.a, 40 | this.p, 41 | this.since, 42 | this.until, 43 | this.limit, 44 | this.search}); 45 | 46 | /// Deserialize a filter from a JSON 47 | Filter.fromJson(Map json) { 48 | ids = json['ids'] == null ? null : List.from(json['ids']); 49 | authors = 50 | json['authors'] == null ? null : List.from(json['authors']); 51 | kinds = json['kinds'] == null ? null : List.from(json['kinds']); 52 | e = json['#e'] == null ? null : List.from(json['#e']); 53 | a = json['#a'] == null ? null : List.from(json['#a']); 54 | p = json['#p'] == null ? null : List.from(json['#p']); 55 | since = json['since']; 56 | until = json['until']; 57 | limit = json['limit']; 58 | search = json['search']; 59 | } 60 | 61 | /// Serialize a filter in JSON 62 | Map toJson() { 63 | final Map data = {}; 64 | if (ids != null) data['ids'] = ids; 65 | if (authors != null) data['authors'] = authors; 66 | if (kinds != null) data['kinds'] = kinds; 67 | if (e != null) data['#e'] = e; 68 | if (a != null) data['#a'] = a; 69 | if (p != null) data['#p'] = p; 70 | if (since != null) data['since'] = since; 71 | if (until != null) data['until'] = until; 72 | if (limit != null) data['limit'] = limit; 73 | if (search != null) data['search'] = search; 74 | return data; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /lib/src/keys.dart: -------------------------------------------------------------------------------- 1 | import 'package:bip340/bip340.dart' as bip340; 2 | import 'package:nostr/nostr.dart'; 3 | 4 | /// Keys encapsulates a public key and a secret key, which are used for tasks such as encrypting and decrypting messages, or creating and verifying digital signatures. 5 | class Keys { 6 | /// An hex-encoded (64 chars) secret key used to decrypt messages or create digital signatures, and it must be kept secret. 7 | late String secret; 8 | 9 | /// A hex-encoded (64 chars) public key used to encrypt messages or verify digital signatures, and it can be shared with anyone. 10 | late String public; 11 | 12 | // String get nsec => Nip19.encode(prefix: Nip19Prefix.nsec, data: secret); 13 | // String get npub => Nip19.encode(prefix: Nip19Prefix.npub, data: public); 14 | 15 | /// Instantiate a Keys from a secret key using HEX or BECH32 encoding 16 | Keys(String secretKey) { 17 | if (RegExp(r'^[0-9A-Fa-f]+$').hasMatch(secretKey)) { 18 | secret = secretKey.toLowerCase(); 19 | public = bip340.getPublicKey(secret); 20 | return; 21 | } 22 | 23 | try { 24 | final nsec = Nip19.decode(payload: secretKey); 25 | if (nsec.prefix != Nip19Prefix.nsec) { 26 | throw Exception('bech32 must have prefix "nsec", got ${nsec.prefix}'); 27 | } 28 | secret = nsec.data; 29 | public = bip340.getPublicKey(secret); 30 | } catch (e) { 31 | throw Exception('Expects HEX or valid Bech32 "nsec".: $e'); 32 | } 33 | } 34 | 35 | /// Wrap the default constructor with a named parameter for those who enjoy them 36 | factory Keys.from({required String secretKey}) { 37 | return Keys(secretKey); 38 | } 39 | 40 | /// Instantiate a Keys from random bytes 41 | Keys.generate() { 42 | secret = generate64RandomHexChars(); 43 | public = bip340.getPublicKey(secret); 44 | } 45 | 46 | /// Encapsulate dart-bip340 sign() so you don't need to add bip340 as a dependency 47 | String sign({required String message}) => 48 | Schnorr.sign(secretKey: secret, message: message); 49 | } 50 | -------------------------------------------------------------------------------- /lib/src/message.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:nostr/nostr.dart'; 4 | 5 | // Used to deserialize any kind of message that a nostr client or relay can transmit. 6 | class Message { 7 | late dynamic message; 8 | late MessageType messageType; 9 | 10 | String get type => messageType.name; 11 | 12 | // nostr message deserializer 13 | Message.deserialize(String payload) { 14 | final dynamic data = json.decode(payload); 15 | if (!MessageType.values.map((e) => e.name).contains(data[0])) { 16 | throw Exception('Unsupported payload (or NIP)'); 17 | } 18 | 19 | messageType = MessageType.from(data[0]); 20 | switch (messageType) { 21 | case MessageType.event: 22 | message = Event.deserialize(payload); 23 | // ignore: deprecated_member_use_from_same_package 24 | if (message.kind == 4) message = EncryptedDirectMessage(message); 25 | case MessageType.ok: 26 | message = Nip20.deserialize(payload); 27 | case MessageType.req: 28 | message = Request.deserialize(payload); 29 | case MessageType.close: 30 | message = Close.deserialize(payload); 31 | case MessageType.eose: 32 | message = Eose.deserialize(payload); 33 | case MessageType.notice: 34 | message = json.encode(data.sublist(1)); 35 | case MessageType.auth: 36 | message = json.encode(data.sublist(1)); 37 | } 38 | } 39 | } 40 | 41 | enum MessageType { 42 | event("EVENT"), 43 | req("REQ"), 44 | close("CLOSE"), 45 | notice("NOTICE"), 46 | eose("EOSE"), 47 | ok("OK"), 48 | auth("AUTH"); 49 | 50 | final String name; 51 | const MessageType(this.name); 52 | 53 | static MessageType from(String name) => 54 | MessageType.values.byName(name.toLowerCase()); 55 | } 56 | -------------------------------------------------------------------------------- /lib/src/nips/nip_001.dart: -------------------------------------------------------------------------------- 1 | import 'package:nostr/nostr.dart'; 2 | 3 | /// Basic Event Kinds 4 | /// 0: set_metadata: the content is set to a stringified JSON object {name: `username`, about: `string`, picture: `url`} describing the user who created the event. A relay may delete past set_metadata events once it gets a new one for the same pubkey. 5 | /// 1: text_note: the content is set to the plaintext content of a note (anything the user wants to say). Do not use Markdown! Clients should not have to guess how to interpret content like [](). Use different event kinds for parsable content. 6 | class Nip1 { 7 | static Event encodeSetMetadata({ 8 | required String content, 9 | required String privkey, 10 | }) { 11 | return Event.from( 12 | kind: 0, 13 | tags: [], 14 | content: content, 15 | privkey: privkey, 16 | ); 17 | } 18 | 19 | static Event encodeTextNote( 20 | String content, 21 | String privkey, { 22 | String? rootEvent, 23 | String? rootEventRelay, 24 | String? replyEvent, 25 | String? replyEventRelay, 26 | List? replyUsers, 27 | List? replyUserRelays, 28 | List? hashTags, 29 | }) { 30 | List> tags = []; 31 | if (rootEvent != null) { 32 | final ETag root = Nip10.rootTag(rootEvent, rootEventRelay ?? ''); 33 | 34 | final List reply = replyEvent == null 35 | ? [] 36 | : [Nip10.replyTag(replyEvent, replyEventRelay ?? '')]; 37 | 38 | final Thread thread = Thread(root, reply, []); 39 | tags = Nip10.toTags(thread); 40 | } 41 | 42 | final List pTags = 43 | Nip10.pTags(replyUsers ?? [], replyUserRelays ?? []); 44 | 45 | for (final pTag in pTags) { 46 | tags.add(["p", pTag.pubkey, pTag.relayURL]); 47 | } 48 | 49 | if (hashTags != null) { 50 | for (final t in hashTags) { 51 | tags.add(['t', t]); 52 | } 53 | } 54 | 55 | return Event.from(kind: 1, tags: tags, content: content, privkey: privkey); 56 | } 57 | 58 | static List? hashTags(List> tags) { 59 | final List hashTags = []; 60 | for (final tag in tags) { 61 | if (tag[0] == 't') hashTags.add(tag[1]); 62 | } 63 | return hashTags; 64 | } 65 | 66 | static String? quoteRepostId(List> tags) { 67 | for (final tag in tags) { 68 | if (tag[0] == 'q') return tag[1]; 69 | } 70 | return null; 71 | } 72 | 73 | static String? groupId(List> tags) { 74 | for (final tag in tags) { 75 | if (tag[0] == 'h') return tag[1]; 76 | } 77 | return null; 78 | } 79 | 80 | static TextNote decodeTextNote(Event event) { 81 | if (event.kind == 1 || event.kind == 11 || event.kind == 12) { 82 | return TextNote( 83 | event.id, 84 | event.pubkey, 85 | event.createdAt, 86 | Nip10.fromTags(event.tags), 87 | event.content, 88 | hashTags(event.tags), 89 | quoteRepostId(event.tags), 90 | groupId(event.tags), 91 | ); 92 | } 93 | throw Exception("${event.kind} is not nip1 compatible"); 94 | } 95 | } 96 | 97 | class TextNote { 98 | String nodeId; 99 | String pubkey; 100 | int createdAt; 101 | Thread? thread; 102 | String content; 103 | List? hashTags; 104 | String? quoteRepostId; 105 | String? groupId; 106 | 107 | TextNote( 108 | this.nodeId, 109 | this.pubkey, 110 | this.createdAt, 111 | this.thread, 112 | this.content, 113 | this.hashTags, 114 | this.quoteRepostId, 115 | this.groupId, 116 | ); 117 | } 118 | -------------------------------------------------------------------------------- /lib/src/nips/nip_002.dart: -------------------------------------------------------------------------------- 1 | import 'package:nostr/nostr.dart'; 2 | 3 | /// Contact List and Petnames 4 | /// 5 | /// A special event with kind 3, meaning "contact list" is defined as having a list of p tags, one for each of the followed/known profiles one is following. 6 | /// 7 | /// Each tag entry should contain the key for the profile, a relay URL where events from that key can be found 8 | /// (can be set to an empty string if not needed), and a local name (or "petname") for that profile (can also be set to an empty string or not provided), 9 | /// i.e., ["p", "32-bytes hex key", "main relay URL", "petname"]. 10 | /// The content can be anything and should be ignored. 11 | class Nip2 { 12 | /// Returns the profils from a contact list event (kind=3) 13 | /// 14 | /// ```dart 15 | /// var event = Event.from( 16 | /// kind: 3, 17 | /// tags: [ 18 | /// ["p", "91cf9..4e5ca", "wss://alicerelay.com/", "alice"], 19 | /// ["p", "14aeb..8dad4", "wss://bobrelay.com/nostr", "bob"], 20 | /// ["p", "612ae..e610f", "ws://carolrelay.com/ws", "carol"], 21 | /// ], 22 | /// content: "", 23 | /// privkey: "5ee1c8000ab28edd64d74a7d951ac2dd559814887b1b9e1ac7c5f89e96125c12", 24 | /// ); 25 | /// 26 | /// List profiles = Nip2.decode(event); 27 | ///``` 28 | static List decode(Event event) { 29 | if (event.kind == 3) { 30 | return toProfiles(event.tags); 31 | } 32 | throw Exception("${event.kind} is not nip2 compatible"); 33 | } 34 | 35 | /// Returns profiles from event.tags 36 | /// 37 | /// ```dart 38 | /// List> tags = [ 39 | /// ["p", "91cf9..4e5ca", "wss://alicerelay.com/", "alice"], 40 | /// ["p", "14aeb..8dad4", "wss://bobrelay.com/nostr", "bob"], 41 | /// ["p", "612ae..e610f", "ws://carolrelay.com/ws", "carol"] 42 | /// ]; 43 | /// List profiles = Nip2.toProfiles(tags); 44 | /// ``` 45 | static List toProfiles(List> tags) { 46 | final List result = []; 47 | for (final tag in tags) { 48 | if (tag[0] == "p") result.add(Profile(tag[1], tag[2], tag[3])); 49 | } 50 | return result; 51 | } 52 | 53 | /// Returns tags from profiles list 54 | /// 55 | /// ```dart 56 | /// List profiles = [Profile("21df6d143fb96c2ec9d63726bf9edc71", "ws://erinrelay.com/ws", "erin")]; 57 | /// List> tags = Nip2.toTags(profiles); 58 | /// ``` 59 | static List> toTags(List profiles) { 60 | final List> result = []; 61 | for (final profile in profiles) { 62 | result.add(["p", profile.key, profile.relay, profile.petname]); 63 | } 64 | return result; 65 | } 66 | } 67 | 68 | /// Each tag entry should contain the key for the profile, a relay URL where events from that key can be found 69 | /// (can be set to an empty string if not needed), and a local name (or "petname") for that profile (can also be set to an empty string or not provided), 70 | /// i.e., ["p", "32-bytes hex key", "main relay URL", "petname"]. 71 | /// The content can be anything and should be ignored. 72 | /// 73 | /// ```dart 74 | /// String key = "91cf9..4e5ca"; 75 | /// String relay = "wss://alicerelay.com/"; 76 | /// String petname = "alice"; 77 | /// var profile = Profile(key, relay, petname); 78 | /// ``` 79 | class Profile { 80 | /// Each tag entry should contain the key for the profile, 81 | String key; 82 | 83 | /// A relay URL where events from that key can be found (can be set to an empty string if not needed) 84 | String relay; 85 | 86 | /// A local name (or "petname") for that profile (can also be set to an empty string or not provided) 87 | String petname; 88 | 89 | /// Default constructor 90 | Profile(this.key, this.relay, this.petname); 91 | } 92 | -------------------------------------------------------------------------------- /lib/src/nips/nip_004.dart: -------------------------------------------------------------------------------- 1 | import 'package:bip340/bip340.dart' as bip340; 2 | import 'package:nostr/src/crypto/nip_004.dart'; 3 | import 'package:nostr/src/event.dart'; 4 | import 'package:nostr/src/utils.dart'; 5 | 6 | const deprecatedMessage = """ 7 | NIP-04 a.k.a EncryptedDirectMessage is controversial, please READ: 8 | - https://github.com/ethicnology/dart-nostr/issues/15 9 | - https://github.com/nostr-protocol/nips/issues/107 10 | """; 11 | 12 | /// A special event with kind 4, meaning "encrypted direct message". 13 | /// 14 | /// content MUST be equal to the base64-encoded, aes-256-cbc encrypted string of anything a user wants to write, encrypted using a shared nip4cipher generated by combining the recipient's public-key with the sender's private-key; 15 | /// this appended by the base64-encoded initialization vector as if it was a querystring parameter named "iv". 16 | /// The format is the following: "content": "`encrypted_text`?iv=`initialization_vector`". 17 | /// 18 | /// tags MUST contain an entry identifying the receiver of the message (such that relays may naturally forward this event to them), in the form ["p", "pubkey, as a hex string"]. 19 | /// 20 | /// tags MAY contain an entry identifying the previous message in a conversation or a message we are explicitly replying to (such that contextual, more organized conversations may happen), in the form ["e", "event_id"]. 21 | /// 22 | /// Note: By default in the libsecp256k1 ECDH implementation, the secret is the SHA256 hash of the shared point (both X and Y coordinates). In Nostr, only the X coordinate of the shared point is used as the secret and it is NOT hashed. If using libsecp256k1, a custom function that copies the X coordinate must be passed as the hashfp argument in secp256k1_ecdh. 23 | /// 24 | /// NIP-04 considered harmful, READ: https://github.com/ethicnology/dart-nostr/issues/15 and https://github.com/nostr-protocol/nips/issues/107 25 | @Deprecated(deprecatedMessage) 26 | class EncryptedDirectMessage extends Event { 27 | /// Default constructor 28 | @Deprecated(deprecatedMessage) 29 | EncryptedDirectMessage(Event event, {verify = true}) 30 | : super( 31 | event.id, 32 | event.pubkey, 33 | event.createdAt, 34 | 4, 35 | event.tags, 36 | event.content, 37 | event.sig, 38 | subscriptionId: event.subscriptionId, 39 | verify: verify, 40 | ); 41 | 42 | /// receive an EncryptedDirectMessage 43 | @Deprecated(deprecatedMessage) 44 | EncryptedDirectMessage.receive(Event event, {verify = true}) 45 | : super( 46 | event.id, 47 | event.pubkey, 48 | event.createdAt, 49 | event.kind, 50 | event.tags, 51 | event.content, 52 | event.sig, 53 | subscriptionId: event.subscriptionId, 54 | verify: verify, 55 | ) { 56 | assert(kind == 4); 57 | } 58 | 59 | /// prepare a EncryptedDirectMessage to send quickly with the minimal needed params 60 | @Deprecated(deprecatedMessage) 61 | factory EncryptedDirectMessage.redact( 62 | String senderPrivkey, 63 | String receiverPubkey, 64 | String message, 65 | ) { 66 | final event = Event.partial( 67 | pubkey: bip340.getPublicKey(senderPrivkey).toLowerCase(), 68 | createdAt: currentUnixTimestampSeconds(), 69 | kind: 4, 70 | tags: [ 71 | ['p', receiverPubkey] 72 | ], 73 | content: nip4cipher( 74 | senderPrivkey, 75 | '02$receiverPubkey', 76 | message, 77 | cipher: true, 78 | ), 79 | ); 80 | event 81 | ..id = event.getEventId() 82 | ..sig = event.getSignature(senderPrivkey); 83 | return EncryptedDirectMessage(event); 84 | } 85 | 86 | /// get receiver public key 87 | String? get receiver => _findTag("p"); 88 | 89 | /// get sender public key 90 | String? get sender => pubkey; 91 | 92 | /// get previous event id –> MAY contain an entry identifying the previous message in a conversation or a message we are explicitly replying to. 93 | String? get previous => _findTag("e"); 94 | 95 | /// get nonce/IV 96 | String get nonce => _findNonce(); 97 | 98 | /// get the deciphered message a.k.a. plaintext 99 | String getPlaintext(String privkey) { 100 | final String ciphertext = content.split("?iv=")[0]; 101 | String plaintext; 102 | try { 103 | plaintext = nip4cipher( 104 | privkey, 105 | "02$pubkey", 106 | ciphertext, 107 | cipher: false, 108 | nonce: nonce, 109 | ); 110 | } catch (e) { 111 | throw Exception("Fail to decipher: $e"); 112 | } 113 | return plaintext; 114 | } 115 | 116 | /// find the given tag prefix and return the value if found 117 | String? _findTag(String prefix) { 118 | const prefix = "p"; 119 | for (final tag in tags) { 120 | if (tag.isNotEmpty && tag[0] == prefix && tag.length > 1) return tag[1]; 121 | } 122 | return null; 123 | } 124 | 125 | /// parse the ciphered content to return the nonce/IV 126 | String _findNonce() { 127 | final List split = content.split("?iv="); 128 | if (split.length != 2) throw Exception("invalid content or non ciphered"); 129 | return split[1]; 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /lib/src/nips/nip_005.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'package:nostr/nostr.dart'; 3 | 4 | /// Mapping Nostr keys to DNS-based internet identifiers 5 | class Nip5 { 6 | /// decode setmetadata event 7 | /// { 8 | /// "pubkey": "b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9", 9 | /// "kind": 0, 10 | /// "content": "{\"name\": \"bob\", \"nip05\": \"bob@example.com\"}" 11 | /// } 12 | static Future decode(Event event) async { 13 | if (event.kind == 0) { 14 | try { 15 | final Map map = json.decode(event.content); 16 | final String dns = map['nip05']; 17 | final List relays = map['relays']; 18 | if (dns.isNotEmpty) { 19 | final List parts = dns.split('@'); 20 | final String name = parts[0]; 21 | final String domain = parts[1]; 22 | return DNS(name, domain, event.pubkey, 23 | relays.map((e) => e.toString()).toList()); 24 | } 25 | } catch (e) { 26 | throw Exception(e.toString()); 27 | } 28 | } 29 | throw Exception("${event.kind} is not nip1 compatible"); 30 | } 31 | 32 | /// encode set metadata event 33 | static Event encode( 34 | String name, String domain, List relays, String privkey) { 35 | if (isValidName(name) && isValidDomain(domain)) { 36 | final String content = generateContent(name, domain, relays); 37 | return Event.from(kind: 0, tags: [], content: content, privkey: privkey); 38 | } else { 39 | throw Exception("not a valid name or domain!"); 40 | } 41 | } 42 | 43 | static bool isValidName(String input) { 44 | final RegExp regExp = RegExp(r'^[a-z0-9_]+$'); 45 | return regExp.hasMatch(input); 46 | } 47 | 48 | static bool isValidDomain(String domain) { 49 | final RegExp regExp = RegExp( 50 | r'^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$', 51 | caseSensitive: false, 52 | ); 53 | return regExp.hasMatch(domain); 54 | } 55 | 56 | static String generateContent( 57 | String name, 58 | String domain, 59 | List relays, 60 | ) { 61 | return json.encode({ 62 | 'name': name, 63 | 'nip05': '$name@$domain', 64 | 'relays': relays, 65 | }); 66 | } 67 | } 68 | 69 | /// 70 | class DNS { 71 | String name; 72 | 73 | String domain; 74 | 75 | String pubkey; 76 | 77 | List relays; 78 | 79 | /// Default constructor 80 | DNS(this.name, this.domain, this.pubkey, this.relays); 81 | } 82 | -------------------------------------------------------------------------------- /lib/src/nips/nip_009.dart: -------------------------------------------------------------------------------- 1 | import 'package:nostr/nostr.dart'; 2 | 3 | /// Event Deletion 4 | /// 5 | /// A special event with kind 5, meaning "deletion request," is defined as having a list of one or more tags referencing events the author is requesting to delete. 6 | /// Each tag entry should follow this format: 7 | /// - ["e", "event ID"] for referencing events by ID. 8 | /// - ["a", "kind:pubkey:d-identifier"] for replaceable events up to the deletion timestamp. 9 | /// The `content` field may contain a reason for the deletion. 10 | /// 11 | /// Example: 12 | /// ```json 13 | /// { 14 | /// "kind": 5, 15 | /// "pubkey": "32-bytes hex-encoded public key", 16 | /// "tags": [ 17 | /// ["e", "dcd59..464a2"], 18 | /// ["e", "968c5..ad7a4"], 19 | /// ["a", "kind:pubkey:d-identifier"] 20 | /// ], 21 | /// "content": "these posts were published by accident" 22 | /// } 23 | /// ``` 24 | class Nip9 { 25 | /// Converts a list of event IDs into a list of tags with "e" entries. 26 | /// 27 | /// ```dart 28 | /// List events = ["event1", "event2"]; 29 | /// List> tags = Nip9.toTags(events); 30 | /// ``` 31 | static List> toTags(List events) { 32 | final List> result = []; 33 | for (final event in events) { 34 | result.add(["e", event]); 35 | } 36 | return result; 37 | } 38 | 39 | /// Encodes a deletion request event from event IDs, reason, and keys. 40 | /// 41 | /// ```dart 42 | /// Event event = Nip9.encode(["event1", "event2"], "Reason", "pubkey", "privkey"); 43 | /// ``` 44 | static Event encode( 45 | List eventIds, 46 | String content, 47 | String pubkey, 48 | String privkey, 49 | ) { 50 | return Event.from( 51 | kind: 5, 52 | tags: toTags(eventIds), 53 | content: content, 54 | pubkey: pubkey, 55 | privkey: privkey, 56 | ); 57 | } 58 | 59 | /// Converts an Event to a Nip9DeletionRequest instance. 60 | /// 61 | /// ```dart 62 | /// Nip9DeletionRequest deleteEvent = Nip9.toDeleteEvent(event); 63 | /// ``` 64 | static Nip9DeletionRequest toDeleteEvent(Event event) { 65 | return Nip9DeletionRequest( 66 | event.pubkey, 67 | tagsToList(event.tags), 68 | event.content, 69 | event.createdAt, 70 | ); 71 | } 72 | 73 | /// Extracts event IDs from tags. 74 | /// 75 | /// ```dart 76 | /// List> tags = [["e", "event1"], ["e", "event2"]]; 77 | /// List eventIds = Nip9.tagsToList(tags); 78 | /// ``` 79 | static List tagsToList(List> tags) { 80 | final List deleteEvents = []; 81 | for (final tag in tags) { 82 | if (tag[0] == "e") deleteEvents.add(tag[1]); 83 | } 84 | return deleteEvents; 85 | } 86 | 87 | /// Decodes a deletion request event into a Nip9DeletionRequest. 88 | /// 89 | /// ```dart 90 | /// Nip9DeletionRequest deleteEvent = Nip9.decode(event); 91 | /// ``` 92 | static Nip9DeletionRequest decode(Event event) { 93 | if (event.kind == 5) return toDeleteEvent(event); 94 | throw Exception("${event.kind} is not nip9 compatible"); 95 | } 96 | } 97 | 98 | /// Represents a deletion request event. 99 | class Nip9DeletionRequest { 100 | /// Public key of the deletion request author. 101 | String pubkey; 102 | 103 | /// List of event IDs requested for deletion. 104 | List deleteEvents; 105 | 106 | /// Reason for deletion (may be empty). 107 | String reason; 108 | 109 | /// Timestamp of the deletion request. 110 | int deleteTime; 111 | 112 | /// Constructor for Nip9DeletionRequest. 113 | Nip9DeletionRequest( 114 | this.pubkey, this.deleteEvents, this.reason, this.deleteTime); 115 | } 116 | -------------------------------------------------------------------------------- /lib/src/nips/nip_010.dart: -------------------------------------------------------------------------------- 1 | /// This NIP describes how to use "e" and "p" tags in text events, 2 | /// especially those that are replies to other text events. 3 | /// It helps clients thread the replies into a tree rooted at the original event. 4 | class Nip10 { 5 | ///{ 6 | /// "tags": [ 7 | /// ["e", , , "root"], 8 | /// ["e", , , "reply"], 9 | /// ["p", , ], 10 | /// ... 11 | /// ], 12 | /// ... 13 | /// } 14 | static Thread fromTags(List> tags) { 15 | ETag root = ETag('', '', ''); 16 | final List etags = []; 17 | final List ptags = []; 18 | for (final tag in tags) { 19 | if (tag[0] == "p") ptags.add(PTag(tag[1], tag[2])); 20 | if (tag[0] == "e") { 21 | if (tag[3] == 'root') { 22 | root = ETag(tag[1], tag[2], tag[3]); 23 | } else { 24 | etags.add(ETag(tag[1], tag[2], tag[3])); 25 | } 26 | } 27 | } 28 | return Thread(root, etags, ptags); 29 | } 30 | 31 | static ETag replyTag(String eventId, String relay) { 32 | return ETag(eventId, relay, 'reply'); 33 | } 34 | 35 | static List pTags(List pubkeys, List relays) { 36 | final List result = []; 37 | for (int i = 0; i < pubkeys.length; ++i) { 38 | result.add(PTag(pubkeys[i], relays.length > i ? relays[i] : '')); 39 | } 40 | return result; 41 | } 42 | 43 | static ETag rootTag(String eventId, String relay) { 44 | return ETag(eventId, relay, 'root'); 45 | } 46 | 47 | static List> toTags(Thread thread) { 48 | final List> result = []; 49 | result.add( 50 | ["e", thread.root.eventId, thread.root.relayURL, thread.root.marker]); 51 | for (final etag in thread.etags) { 52 | result.add(["e", etag.eventId, etag.relayURL, etag.marker]); 53 | } 54 | for (final ptag in thread.ptags) { 55 | result.add(["p", ptag.pubkey, ptag.relayURL]); 56 | } 57 | return result; 58 | } 59 | } 60 | 61 | class ETag { 62 | String eventId; 63 | String relayURL; 64 | String marker; // root, reply, mention 65 | 66 | ETag(this.eventId, this.relayURL, this.marker); 67 | } 68 | 69 | class PTag { 70 | String pubkey; 71 | String relayURL; 72 | 73 | PTag(this.pubkey, this.relayURL); 74 | } 75 | 76 | class Thread { 77 | ETag root; 78 | List etags; 79 | List ptags; 80 | Thread(this.root, this.etags, this.ptags); 81 | } 82 | -------------------------------------------------------------------------------- /lib/src/nips/nip_013.dart: -------------------------------------------------------------------------------- 1 | import 'dart:math'; 2 | 3 | /// Proof of Work (NIP-13) 4 | /// 5 | /// NIP-13 introduces Proof of Work (PoW) for nostr notes to deter spam. PoW is validated by counting the number of leading zero bits in a note's ID (difficulty). 6 | /// 7 | /// Example: 8 | /// An ID `000000000e9d97a1ab09fc381030b346cdd7a142ad57e6df0b46dc9bef6c7e2d` has a difficulty of `36` with 36 leading zero bits. 9 | /// 10 | /// Mining involves generating an ID with a desired difficulty by iteratively modifying a `nonce` tag. 11 | class Nip13 { 12 | /// Calculates the number of leading zero bits in a hexadecimal string. 13 | /// 14 | /// ```dart 15 | /// int difficulty = Nip13.countLeadingZeroes("000000000e9d97a1ab09fc381030b346cdd7a142ad57e6df0b46dc9bef6c7e2d"); 16 | /// print(difficulty); // 36 17 | /// ``` 18 | static int countLeadingZeroes(String hex) { 19 | int count = 0; 20 | for (int i = 0; i < hex.length; i++) { 21 | final int nibble = int.parse(hex[i], radix: 16); 22 | if (nibble == 0) { 23 | count += 4; 24 | } else { 25 | count += 4 - min(4, nibble.bitLength); 26 | break; 27 | } 28 | } 29 | return count; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /lib/src/nips/nip_017.dart: -------------------------------------------------------------------------------- 1 | import 'package:nostr/nostr.dart'; 2 | 3 | class Nip17 { 4 | static Future encode({ 5 | required String message, 6 | required String authorPrivkey, 7 | required String receiverPubkey, 8 | }) async { 9 | final authorPubkey = Keys(authorPrivkey).public; 10 | 11 | final rumor = Event.partial( 12 | pubkey: authorPubkey, 13 | kind: 14, 14 | content: message, 15 | tags: [ 16 | ['p', receiverPubkey] 17 | ], 18 | ); 19 | rumor.id = rumor.getEventId(); 20 | // Kind 14s MUST never be signed. 21 | // If it is signed, the message might leak to relays and become fully public. 22 | rumor.sig = ''; 23 | 24 | return Nip59.wrap( 25 | rumor: rumor, 26 | authorPrivkey: authorPrivkey, 27 | recipientPubkey: receiverPubkey, 28 | ); 29 | } 30 | 31 | static Future decode({ 32 | required Event giftWrap, 33 | required String receiverPrivkey, 34 | }) async { 35 | final dm = await Nip59.unwrap( 36 | giftWrap: giftWrap, 37 | recipientPrivkey: receiverPrivkey, 38 | ); 39 | 40 | if (dm.kind != 14) { 41 | throw Exception('NIP-17 define private direct messages with kind=14'); 42 | } 43 | 44 | return dm; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /lib/src/nips/nip_019.dart: -------------------------------------------------------------------------------- 1 | import 'dart:typed_data'; 2 | 3 | import 'package:convert/convert.dart'; 4 | import 'package:nostr/src/nips/nip_019_utils.dart'; 5 | 6 | /// bech32-encoded entities 7 | /// 8 | /// This NIP standardizes bech32-formatted strings that can be used to display keys, 9 | /// ids and other information in clients. These formats are not meant to be used anywhere 10 | /// in the core protocol, they are only meant for displaying to users, copy-pasting, 11 | /// sharing, rendering QR codes and inputting data. 12 | class Nip19 { 13 | static const _shareableIdentifiersPrefixes = [ 14 | Nip19Prefix.nprofile, 15 | Nip19Prefix.nevent, 16 | Nip19Prefix.naddr 17 | ]; 18 | 19 | /// The bech32 npub `npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6` 20 | /// translates to the hex public key `3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d` 21 | static ({Nip19Prefix prefix, String data}) decode({required String payload}) { 22 | final decoded = bech32Decode(payload); 23 | if (_shareableIdentifiersPrefixes.contains(decoded.prefix)) { 24 | throw Exception('use ${Nip19.decodeShareableIdentifiers} instead'); 25 | } 26 | return decoded; 27 | } 28 | 29 | /// The hex public key `3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d` 30 | /// translates to `npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6` 31 | static String encode({ 32 | required Nip19Prefix prefix, 33 | required String data, 34 | }) { 35 | if (_shareableIdentifiersPrefixes.contains(prefix)) { 36 | throw Exception('use ${Nip19.encodeShareableIdentifiers} instead'); 37 | } 38 | return bech32Encode(prefix, data); 39 | } 40 | 41 | /// Encode shareable identifiers (nprofile, nevent, naddr) as TLV data 42 | static String encodeShareableIdentifiers({ 43 | required Nip19Prefix prefix, 44 | required String special, 45 | List? relays, 46 | String? author, 47 | int? kind, 48 | }) { 49 | if (!_shareableIdentifiersPrefixes.contains(prefix)) { 50 | throw Exception('$prefix not in $_shareableIdentifiersPrefixes'); 51 | } 52 | 53 | // 0: special 54 | if (prefix == Nip19Prefix.naddr) { 55 | special = special.codeUnits 56 | .map((number) => number.toRadixString(16).padLeft(2, '0')) 57 | .join(); 58 | } 59 | var result = 60 | '00${hex.decode(special).length.toRadixString(16).padLeft(2, '0')}$special'; 61 | 62 | // 1: relay 63 | if (relays != null) { 64 | for (final relay in relays) { 65 | result = '${result}01'; 66 | final value = relay.codeUnits 67 | .map((number) => number.toRadixString(16).padLeft(2, '0')) 68 | .join(); 69 | result = 70 | '$result${hex.decode(value).length.toRadixString(16).padLeft(2, '0')}$value'; 71 | } 72 | } 73 | 74 | // 2: author 75 | if (author != null) { 76 | result = '${result}02'; 77 | result = 78 | '$result${hex.decode(author).length.toRadixString(16).padLeft(2, '0')}$author'; 79 | } 80 | 81 | // 3: kind 82 | if (kind != null) { 83 | result = '${result}03'; 84 | final byteData = ByteData(4); 85 | byteData.setUint32(0, kind); 86 | final value = List.generate( 87 | byteData.lengthInBytes, 88 | (index) => 89 | byteData.getUint8(index).toRadixString(16).padLeft(2, '0')) 90 | .join(); 91 | result = 92 | '$result${hex.decode(value).length.toRadixString(16).padLeft(2, '0')}$value'; 93 | } 94 | return bech32Encode(prefix, result, length: result.length + 90); 95 | } 96 | 97 | /// For these events, the contents are a binary-encoded list of TLV (type-length-value), 98 | /// with T and L being 1 byte each (uint8, i.e. a number in the range of 0-255), 99 | /// and V being a sequence of bytes of the size indicated by L. 100 | /// 101 | /// 0: special depends on the bech32 prefix: 102 | /// - for nprofile it will be the 32 bytes of the profile public key 103 | /// - for nevent it will be the 32 bytes of the event id 104 | /// - for naddr, it is the identifier (the "d" tag) of the event being referenced. For normal replaceable events use an empty string. 105 | /// 106 | /// 1: relay for nprofile, nevent and naddr, optionally, a relay in which the entity 107 | /// (profile or event) is more likely to be found, encoded as ascii this may be included multiple times 108 | /// 109 | /// 2: author 110 | /// - for naddr, the 32 bytes of the pubkey of the event 111 | /// - for nevent, optionally, the 32 bytes of the pubkey of the event 112 | /// 113 | /// 3: kind 114 | /// - for naddr, the 32-bit unsigned integer of the kind, big-endian 115 | /// - for nevent, optionally, the 32-bit unsigned integer of the kind, big-endian 116 | static ShareableIdentifiers decodeShareableIdentifiers({ 117 | required String payload, 118 | }) { 119 | try { 120 | String special = ''; 121 | final List relays = []; 122 | String? author; 123 | int? kind; 124 | final decoded = bech32Decode(payload, length: payload.length); 125 | final data = hex.decode(decoded.data); 126 | 127 | var index = 0; 128 | while (index < data.length) { 129 | final type = data[index++]; 130 | final length = data[index++]; 131 | 132 | final value = Uint8List.fromList(data.sublist(index, index + length)); 133 | index += length; 134 | 135 | if (type == 0) { 136 | special = (decoded.prefix == Nip19Prefix.naddr) 137 | ? String.fromCharCodes(value) 138 | : hex.encode(value); 139 | } else if (type == 1) { 140 | relays.add(String.fromCharCodes(value)); 141 | } else if (type == 2) { 142 | author = hex.encode(value); 143 | } else if (type == 3) { 144 | final byteData = ByteData.sublistView(value); 145 | kind = byteData.getUint32(0); 146 | } 147 | } 148 | 149 | return ShareableIdentifiers( 150 | prefix: decoded.prefix, 151 | special: special, 152 | relays: relays, 153 | author: author, 154 | kind: kind, 155 | ); 156 | } catch (e) { 157 | throw Exception('Failed to decode shareable entity: $e'); 158 | } 159 | } 160 | } 161 | 162 | /// Represents all the prefixes availables 163 | /// nrelay is deprecated 164 | enum Nip19Prefix { 165 | nsec, 166 | npub, 167 | note, 168 | nprofile, 169 | nevent, 170 | naddr; 171 | 172 | static Nip19Prefix from(String name) => 173 | Nip19Prefix.values.byName(name.toLowerCase()); 174 | } 175 | 176 | /// Shareable identifiers with extra metadata 177 | /// When sharing a profile or an event, an app may decide to include relay information 178 | /// and other metadata such that other apps can locate and display these entities 179 | /// more easily. 180 | class ShareableIdentifiers { 181 | final Nip19Prefix prefix; 182 | final String special; 183 | List relays; 184 | String? author; 185 | int? kind; 186 | 187 | ShareableIdentifiers({ 188 | required this.prefix, 189 | required this.special, 190 | this.relays = const [], 191 | this.author, 192 | this.kind, 193 | }); 194 | } 195 | -------------------------------------------------------------------------------- /lib/src/nips/nip_019_utils.dart: -------------------------------------------------------------------------------- 1 | import 'package:bech32/bech32.dart'; 2 | import 'package:convert/convert.dart'; 3 | import 'package:nostr/nostr.dart'; 4 | 5 | String bech32Encode(Nip19Prefix prefix, String hexData, {int? length}) { 6 | final data = hex.decode(hexData); 7 | final convertedData = _convertBits(data, 8, 5, true); 8 | final bech32Data = Bech32(prefix.name, convertedData); 9 | if (length != null) { 10 | return bech32.encode(bech32Data, length); 11 | } 12 | return bech32.encode(bech32Data); 13 | } 14 | 15 | ({Nip19Prefix prefix, String data}) bech32Decode( 16 | String bech32Data, { 17 | int? length, 18 | }) { 19 | final decodedData = length != null 20 | ? bech32.decode(bech32Data, length) 21 | : bech32.decode(bech32Data); 22 | final convertedData = _convertBits(decodedData.data, 5, 8, false); 23 | final hexData = hex.encode(convertedData); 24 | return (prefix: Nip19Prefix.from(decodedData.hrp), data: hexData); 25 | } 26 | 27 | List _convertBits(List data, int fromBits, int toBits, bool pad) { 28 | var acc = 0; 29 | var bits = 0; 30 | final maxv = (1 << toBits) - 1; 31 | final result = []; 32 | 33 | for (final value in data) { 34 | if (value < 0 || value >> fromBits != 0) { 35 | throw Exception('Invalid value: $value'); 36 | } 37 | acc = (acc << fromBits) | value; 38 | bits += fromBits; 39 | 40 | while (bits >= toBits) { 41 | bits -= toBits; 42 | result.add((acc >> bits) & maxv); 43 | } 44 | } 45 | 46 | if (pad) { 47 | if (bits > 0) { 48 | result.add((acc << (toBits - bits)) & maxv); 49 | } 50 | } else if (bits >= fromBits || ((acc << (toBits - bits)) & maxv) != 0) { 51 | throw Exception('Invalid data'); 52 | } 53 | 54 | return result; 55 | } 56 | -------------------------------------------------------------------------------- /lib/src/nips/nip_020.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | /// When submitting events to relays, clients currently have no way to know if an event was successfully committed to the database. 4 | /// This NIP introduces the concept of command results which are like NOTICE's except provide more information about if an event was accepted or rejected. 5 | /// 6 | /// Event successfully written to the database: 7 | /// 8 | /// ["OK", "b1a649ebe8b435ec71d3784793f3bbf4b93e64e17568a741aecd4c7ddeafce30", true, ""] 9 | /// 10 | /// Event successfully written to the database because of a reason: 11 | /// 12 | /// ["OK", "b1a649ebe8b435ec71d3784793f3bbf4b93e64e17568a741aecd4c7ddeafce30", true, "pow: difficulty 25>=24"] 13 | /// 14 | /// Event blocked due to ip filter: 15 | /// 16 | /// ["OK", "b1a649ebe8...", false, "blocked: tor exit nodes not allowed"] 17 | /// 18 | /// … 19 | class Nip20 { 20 | late String eventId; 21 | late bool status; 22 | late String message; 23 | 24 | /// Default constructor 25 | Nip20(this.eventId, this.status, this.message); 26 | 27 | /// Serialize to nostr close message 28 | /// - ["OK", "event_id", true|false, "message"] 29 | String serialize() => json.encode(["OK", eventId, status, message]); 30 | 31 | /// Deserialize a nostr close message 32 | /// - ["OK", "event_id", true|false, "message"] 33 | Nip20.deserialize(String payload) { 34 | final data = json.decode(payload); 35 | assert(data.length == 4); 36 | eventId = data[1]; 37 | status = data[2]; 38 | message = data[3]; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /lib/src/nips/nip_021.dart: -------------------------------------------------------------------------------- 1 | /// A utility class to handle Nostr URIs according to NIP-21 specification. 2 | /// Provides encode, decode functionalities for Nostr URIs. 3 | class Nip21 { 4 | static const String _prefix = 'nostr:'; 5 | 6 | /// Parses a `nostr:` URI and extracts the identifier. 7 | /// 8 | /// Throws an [Exception] if the prefix `nostr:` is missing 9 | static String decode(String uri) { 10 | if (!uri.startsWith(_prefix)) { 11 | throw Exception('Invalid Nostr URI: must start with "nostr:"'); 12 | } 13 | 14 | return uri.substring(_prefix.length); 15 | } 16 | 17 | /// Generates a `nostr:` URI from a given content 18 | static String encode(String content) => _prefix + content; 19 | } 20 | -------------------------------------------------------------------------------- /lib/src/nips/nip_023.dart: -------------------------------------------------------------------------------- 1 | import 'package:nostr/nostr.dart'; 2 | 3 | /// A utility class to handle Nostr long-form content a.k.a. Articles according to NIP-23. 4 | /// Provides decoding, and validation functionalities for Nostr articles. 5 | /// 6 | /// Example usage: 7 | /// ```dart 8 | /// var article = Nip23.decode(event); 9 | /// ``` 10 | class Nip23 { 11 | static const int kindArticle = 30023; 12 | static const int kindDraft = 30024; 13 | 14 | /// Returns a [Nip23Article] instance representing the decoded event. 15 | /// 16 | /// Throws an [Exception] if the event is not a valid NIP-23 kind. 17 | static Nip23Article decode(Event event) => Nip23Article.fromEvent(event); 18 | } 19 | 20 | /// Represents a Nostr long-form content event according to NIP-23. 21 | /// Provides a structured way to handle article events instead of using raw Maps. 22 | class Nip23Article { 23 | /// The article's content in Markdown format. 24 | final String content; 25 | 26 | /// The public key of the author. 27 | final String pubkey; 28 | 29 | /// Unix timestamp of the event creation. 30 | final int createdAt; 31 | 32 | /// A unique identifier for the article a.k.a `d` tag. 33 | final String articleId; 34 | 35 | /// (Optional) The title of the article. 36 | final String? title; 37 | 38 | /// (Optional) URL of an image associated with the article. 39 | final String? image; 40 | 41 | /// (Optional) A short summary of the article. 42 | final String? summary; 43 | 44 | /// (Optional) Unix timestamp of the first publication. 45 | final int? publishedAt; 46 | 47 | /// (Optional) List of topics (hashtags). 48 | final List? topics; 49 | 50 | /// (Optional) Extra tags for metadata. 51 | final List>? additionalTags; 52 | 53 | /// should be either [Nip23.kindArticle] or [Nip23.kindDraft]. 54 | final int kind; 55 | 56 | /// Constructs a [Nip23Article]. 57 | Nip23Article({ 58 | required this.content, 59 | required this.pubkey, 60 | required this.createdAt, 61 | required this.articleId, 62 | this.title, 63 | this.image, 64 | this.summary, 65 | this.publishedAt, 66 | this.topics, 67 | this.additionalTags, 68 | this.kind = Nip23.kindArticle, 69 | }) { 70 | if (kind != Nip23.kindArticle && kind != Nip23.kindDraft) { 71 | throw Exception('Invalid kind for Nip23Article'); 72 | } 73 | } 74 | 75 | /// Factory constructor to create a [Nip23Article] from an [Event] instance. 76 | /// 77 | /// Throws an [Exception] if any required field is missing or invalid. 78 | factory Nip23Article.fromEvent(Event event) { 79 | if (event.kind != Nip23.kindArticle && event.kind != Nip23.kindDraft) { 80 | throw Exception('Invalid NIP-23 kind: ${event.kind}.'); 81 | } 82 | 83 | final articleId = _getTagValue(event.tags, 'd'); 84 | if (articleId == null) { 85 | throw Exception('Missing required tag: d (articleId).'); 86 | } 87 | 88 | final title = _getTagValue(event.tags, 'title'); 89 | final image = _getTagValue(event.tags, 'image'); 90 | final summary = _getTagValue(event.tags, 'summary'); 91 | final publishedAtStr = _getTagValue(event.tags, 'published_at'); 92 | final topics = _getTagValues(event.tags, 't'); 93 | 94 | // Extract additional tags by excluding known event.tags 95 | List>? additionalTags = event.tags.where((tag) { 96 | return !['d', 'title', 'image', 'summary', 'published_at', 't'] 97 | .contains(tag[0]); 98 | }).toList(); 99 | if (additionalTags.isEmpty) additionalTags = null; 100 | 101 | return Nip23Article( 102 | content: event.content, 103 | pubkey: event.pubkey, 104 | createdAt: event.createdAt, 105 | articleId: articleId, 106 | title: title, 107 | image: image, 108 | summary: summary, 109 | publishedAt: publishedAtStr != null ? int.tryParse(publishedAtStr) : null, 110 | topics: topics, 111 | additionalTags: additionalTags, 112 | kind: event.kind, 113 | ); 114 | } 115 | 116 | // Helper to extract single tag value. 117 | static String? _getTagValue(List> tags, String tagName) { 118 | for (final tag in tags) { 119 | if (tag.isNotEmpty && tag[0] == tagName) { 120 | return tag.length > 1 ? tag[1] : null; 121 | } 122 | } 123 | return null; 124 | } 125 | 126 | // Helper to extract multiple tag values. 127 | static List? _getTagValues(List> tags, String tagName) { 128 | final values = []; 129 | for (final tag in tags) { 130 | if (tag.isNotEmpty && tag[0] == tagName && tag.length > 1) { 131 | values.add(tag[1]); 132 | } 133 | } 134 | return values.isNotEmpty ? values : null; 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /lib/src/nips/nip_028.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'package:nostr/nostr.dart'; 3 | 4 | /// Public Chat & Channel 5 | class Nip28 { 6 | static Channel getChannelCreation(Event event) { 7 | try { 8 | final Map content = json.decode(event.content); 9 | if (event.kind == 40) { 10 | // create channel 11 | final Map additional = Map.from(content); 12 | final String? name = additional.remove("name"); 13 | final String? about = additional.remove("about"); 14 | final String? picture = additional.remove("picture"); 15 | return Channel( 16 | event.id, name!, about!, picture!, event.pubkey, additional); 17 | } else { 18 | throw Exception("${event.kind} is not nip28 compatible"); 19 | } 20 | } catch (e) { 21 | throw Exception(e.toString()); 22 | } 23 | } 24 | 25 | static Channel getChannelMetadata(Event event) { 26 | try { 27 | final Map content = json.decode(event.content); 28 | if (event.kind == 41) { 29 | // create channel 30 | final Map additional = Map.from(content); 31 | final String? name = additional.remove("name"); 32 | final String? about = additional.remove("about"); 33 | final String? picture = additional.remove("picture"); 34 | String? channelId; 35 | String? relay; 36 | for (final tag in event.tags) { 37 | if (tag[0] == "e") { 38 | channelId = tag[1]; 39 | relay = tag[2]; 40 | } 41 | } 42 | final Channel result = Channel( 43 | channelId!, name!, about!, picture!, event.pubkey, additional); 44 | result.relay = relay; 45 | return result; 46 | } else { 47 | throw Exception("${event.kind} is not nip28 compatible"); 48 | } 49 | } catch (e) { 50 | throw Exception(e.toString()); 51 | } 52 | } 53 | 54 | static ChannelMessage getChannelMessage(Event event) { 55 | try { 56 | if (event.kind == 42) { 57 | final content = event.content; 58 | final Thread thread = Nip10.fromTags(event.tags); 59 | final String channelId = thread.root.eventId; 60 | return ChannelMessage( 61 | channelId, event.pubkey, content, thread, event.createdAt); 62 | } 63 | throw Exception("${event.kind} is not nip28 compatible"); 64 | } catch (e) { 65 | throw Exception(e.toString()); 66 | } 67 | } 68 | 69 | static ChannelMessageHidden getMessageHidden(Event event) { 70 | try { 71 | if (event.kind == 43) { 72 | String? messageId; 73 | for (final tag in event.tags) { 74 | if (tag[0] == "e") { 75 | messageId = tag[1]; 76 | break; 77 | } 78 | } 79 | final Map content = json.decode(event.content); 80 | final String reason = content['reason']; 81 | return ChannelMessageHidden( 82 | event.pubkey, messageId!, reason, event.createdAt); 83 | } 84 | throw Exception("${event.kind} is not nip28(hide message) compatible"); 85 | } catch (e) { 86 | throw Exception(e.toString()); 87 | } 88 | } 89 | 90 | static ChannelUserMuted getUserMuted(Event event) { 91 | try { 92 | if (event.kind == 44) { 93 | String? userPubkey; 94 | for (final tag in event.tags) { 95 | if (tag[0] == "p") { 96 | userPubkey = tag[1]; 97 | break; 98 | } 99 | } 100 | final Map content = json.decode(event.content); 101 | final String reason = content['reason']; 102 | return ChannelUserMuted( 103 | event.pubkey, userPubkey!, reason, event.createdAt); 104 | } 105 | throw Exception("${event.kind} is not nip28(mute user) compatible"); 106 | } catch (e) { 107 | throw Exception(e.toString()); 108 | } 109 | } 110 | 111 | static Event createChannel(String name, String about, String picture, 112 | Map additional, String privkey) { 113 | final Map map = { 114 | 'name': name, 115 | 'about': about, 116 | 'picture': picture, 117 | }; 118 | map.addAll(additional); 119 | return Event.from( 120 | kind: 40, 121 | tags: [], 122 | content: json.encode(map), 123 | privkey: privkey, 124 | ); 125 | } 126 | 127 | static Event setChannelMetaData( 128 | String name, 129 | String about, 130 | String picture, 131 | Map additional, 132 | String channelId, 133 | String relayURL, 134 | String privkey) { 135 | final Map map = { 136 | 'name': name, 137 | 'about': about, 138 | 'picture': picture 139 | }; 140 | map.addAll(additional); 141 | return Event.from( 142 | kind: 41, 143 | tags: [ 144 | ["e", channelId, relayURL] 145 | ], 146 | content: json.encode(map), 147 | privkey: privkey, 148 | ); 149 | } 150 | 151 | static Event sendChannelMessage( 152 | String channelId, 153 | String content, 154 | String privkey, { 155 | String? relay, 156 | List? etags, 157 | List? ptags, 158 | }) { 159 | final Thread thread = 160 | Thread(Nip10.rootTag(channelId, relay ?? ''), etags ?? [], ptags ?? []); 161 | return Event.from( 162 | kind: 42, 163 | tags: Nip10.toTags(thread), 164 | content: content, 165 | privkey: privkey, 166 | ); 167 | } 168 | 169 | static Event hideChannelMessage( 170 | String messageId, 171 | String reason, 172 | String privkey, 173 | ) { 174 | return Event.from( 175 | kind: 43, 176 | tags: [ 177 | ["e", messageId] 178 | ], 179 | content: json.encode({'reason': reason}), 180 | privkey: privkey, 181 | ); 182 | } 183 | 184 | static Event muteUser(String pubkey, String reason, String privkey) { 185 | return Event.from( 186 | kind: 44, 187 | tags: [ 188 | ["p", pubkey] 189 | ], 190 | content: json.encode({'reason': reason}), 191 | privkey: privkey); 192 | } 193 | } 194 | 195 | /// channel info 196 | class Channel { 197 | /// channel create event id 198 | String channelId; 199 | 200 | String name; 201 | 202 | String about; 203 | 204 | String picture; 205 | 206 | String owner; 207 | 208 | String? relay; 209 | 210 | /// Clients MAY add additional metadata fields. 211 | Map additional; 212 | 213 | /// Default constructor 214 | Channel(this.channelId, this.name, this.about, this.picture, this.owner, 215 | this.additional); 216 | } 217 | 218 | /// messages in channel 219 | class ChannelMessage { 220 | String channelId; 221 | String sender; 222 | String content; 223 | Thread thread; 224 | int createTime; 225 | 226 | ChannelMessage( 227 | this.channelId, this.sender, this.content, this.thread, this.createTime); 228 | } 229 | 230 | class ChannelMessageHidden { 231 | String operator; 232 | String messageId; 233 | String reason; 234 | int createTime; 235 | 236 | ChannelMessageHidden( 237 | this.operator, this.messageId, this.reason, this.createTime); 238 | } 239 | 240 | class ChannelUserMuted { 241 | String operator; 242 | String userPubkey; 243 | String reason; 244 | int createTime; 245 | 246 | ChannelUserMuted( 247 | this.operator, this.userPubkey, this.reason, this.createTime); 248 | } 249 | -------------------------------------------------------------------------------- /lib/src/nips/nip_044.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'dart:typed_data'; 3 | import 'package:elliptic/ecdh.dart'; 4 | import 'package:elliptic/elliptic.dart'; 5 | import 'package:nostr/nostr.dart'; 6 | 7 | /// The NIP introduces a new data format for keypair-based encryption. This NIP is versioned to allow multiple algorithm choices to exist simultaneously. This format may be used for many things, but MUST be used in the context of a signed event as described in NIP-01. 8 | class Nip44 { 9 | static Future encrypt({ 10 | required String plaintext, 11 | required String senderSecretKey, 12 | required String recipientPublicKey, 13 | List? customNonce, 14 | List? customConversationKey, 15 | }) async { 16 | // Step 1: Compute Shared Secret 17 | final sharedSecret = customConversationKey ?? 18 | computeSharedSecret( 19 | secretKeyHex: senderSecretKey, 20 | publicKeyHex: recipientPublicKey, 21 | ); 22 | 23 | // Step 2: Derive Conversation Key 24 | final conversationKey = customConversationKey ?? 25 | deriveConversationKey(sharedSecret: sharedSecret); 26 | 27 | // Step 3: Generate or Use Custom Nonce 28 | final nonce = customNonce ?? Uint8List.fromList(generateRandomBytes(32)); 29 | 30 | // Step 4: Derive Message Keys 31 | final keys = deriveMessageKeys(conversationKey, nonce); 32 | final chachaKey = keys['chachaKey']!; 33 | final chachaNonce = keys['chachaNonce']!; 34 | final hmacKey = keys['hmacKey']!; 35 | 36 | // Step 5: Pad Plaintext 37 | final paddedPlaintext = pad(utf8.encode(plaintext)); 38 | 39 | // Step 6: Encrypt 40 | final ciphertext = chacha20(chachaKey, chachaNonce, paddedPlaintext, true); 41 | 42 | // Step 7: Calculate MAC 43 | final mac = calculateMac(hmacKey, nonce, ciphertext); 44 | 45 | // Step 8: Construct Payload 46 | return constructPayload(nonce, ciphertext, mac); 47 | } 48 | 49 | static Future decrypt({ 50 | required String payload, 51 | required String recipientSecretKey, 52 | required String senderPublicKey, 53 | List? customConversationKey, 54 | }) async { 55 | // Step 1: Compute Shared Secret 56 | final sharedSecret = customConversationKey ?? 57 | computeSharedSecret( 58 | secretKeyHex: recipientSecretKey, 59 | publicKeyHex: senderPublicKey, 60 | ); 61 | 62 | // Step 2: Derive Conversation Key 63 | final conversationKey = customConversationKey ?? 64 | deriveConversationKey(sharedSecret: sharedSecret); 65 | 66 | // Step 3: Parse Payload 67 | final parsed = parsePayload(payload); 68 | final nonce = parsed['nonce']; 69 | final ciphertext = parsed['ciphertext']; 70 | final mac = parsed['mac']; 71 | 72 | // Step 4: Derive Message Keys 73 | final keys = deriveMessageKeys(conversationKey, nonce); 74 | final chachaKey = keys['chachaKey']!; 75 | final chachaNonce = keys['chachaNonce']!; 76 | final hmacKey = keys['hmacKey']!; 77 | 78 | // Step 5: Verify MAC 79 | verifyMac(hmacKey, nonce, ciphertext, mac); 80 | 81 | // Step 6: Decrypt 82 | final paddedPlaintext = chacha20(chachaKey, chachaNonce, ciphertext, false); 83 | 84 | // Step 7: Unpad Plaintext 85 | final plaintextBytes = unpad(paddedPlaintext); 86 | 87 | return utf8.decode(plaintextBytes); 88 | } 89 | 90 | static List computeSharedSecret({ 91 | required String secretKeyHex, 92 | required String publicKeyHex, 93 | }) { 94 | final ec = getS256(); 95 | final secretKey = PrivateKey.fromHex(ec, secretKeyHex); 96 | final publicKey = PublicKey.fromHex(ec, checkPublicKey(publicKeyHex)); 97 | final sec = computeSecret(secretKey, publicKey); 98 | return sec; 99 | } 100 | 101 | static List deriveConversationKey({required List sharedSecret}) { 102 | final salt = utf8.encode('nip44-v2'); 103 | 104 | final conversationKey = hkdfExtract( 105 | ikm: sharedSecret, 106 | salt: Uint8List.fromList(salt), 107 | ); 108 | 109 | return conversationKey; 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /lib/src/nips/nip_044_utils.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'dart:typed_data'; 3 | import 'package:convert/convert.dart'; 4 | import 'package:pointycastle/export.dart'; 5 | 6 | // ignore: non_constant_identifier_names 7 | final HMAC_SHA256_64 = HMac(SHA256Digest(), 64); 8 | 9 | Map> deriveMessageKeys( 10 | List conversationKey, 11 | List nonce, 12 | ) { 13 | if (conversationKey.length != 32) { 14 | throw const FormatException('Invalid conversation key length'); 15 | } 16 | if (nonce.length != 32) { 17 | throw const FormatException('Invalid nonce length'); 18 | } 19 | 20 | final hkdfOutput = hkdfExpand( 21 | prk: conversationKey, 22 | info: nonce, 23 | length: 76, 24 | ); 25 | 26 | return { 27 | 'chachaKey': hkdfOutput.sublist(0, 32), 28 | 'chachaNonce': hkdfOutput.sublist(32, 44), // Should be 12 bytes 29 | 'hmacKey': hkdfOutput.sublist(44, 76), 30 | }; 31 | } 32 | 33 | List pad(List plaintext) { 34 | final unpaddedLen = plaintext.length; 35 | if (unpaddedLen < 1 || unpaddedLen > 65535) { 36 | throw Exception('Invalid plaintext length'); 37 | } 38 | 39 | final paddedLen = calcPaddedLen(unpaddedLen); 40 | final padded = Uint8List(paddedLen + 2); 41 | 42 | // First two bytes are the length in big-endian 43 | padded[0] = (unpaddedLen >> 8) & 0xFF; 44 | padded[1] = unpaddedLen & 0xFF; 45 | 46 | padded.setRange(2, 2 + unpaddedLen, plaintext); 47 | 48 | // The rest is zeros by default 49 | return padded; 50 | } 51 | 52 | int calcPaddedLen(int unpaddedLen) { 53 | final nextPower = 1 << ((unpaddedLen - 1).bitLength); 54 | final chunk = nextPower <= 256 ? 32 : nextPower ~/ 8; 55 | if (unpaddedLen <= 32) { 56 | return 32; 57 | } else { 58 | return chunk * ((unpaddedLen - 1) ~/ chunk + 1); 59 | } 60 | } 61 | 62 | List chacha20( 63 | List key, 64 | List nonce, 65 | List data, 66 | bool forEncryption, // encryption (true) or decryption (false). 67 | ) { 68 | final keyParam = KeyParameter(Uint8List.fromList(key)); 69 | final params = ParametersWithIV(keyParam, Uint8List.fromList(nonce)); 70 | final input = Uint8List.fromList(data); 71 | final output = Uint8List(input.length); 72 | 73 | final cipher = ChaCha7539Engine(); 74 | cipher.init(forEncryption, params); 75 | cipher.processBytes(input, 0, input.length, output, 0); 76 | 77 | return output; 78 | } 79 | 80 | String constructPayload(List nonce, List ciphertext, List mac) { 81 | final List payloadBytes = [ 82 | 0x02, // Version 83 | ...nonce, 84 | ...ciphertext, 85 | ...mac, 86 | ]; 87 | return base64.encode(payloadBytes); 88 | } 89 | 90 | List hkdfExtract({required List ikm, required List salt}) { 91 | final u8salt = Uint8List.fromList(salt); 92 | final u8ikm = Uint8List.fromList(ikm); 93 | final hmacSha256 = HMAC_SHA256_64..init(KeyParameter(u8salt)); 94 | return hmacSha256.process(u8ikm); 95 | } 96 | 97 | List hkdfExpand({ 98 | required List prk, 99 | required List info, 100 | required int length, 101 | }) { 102 | const hashLen = 32; 103 | final int n = (length + hashLen - 1) ~/ hashLen; 104 | final okm = []; 105 | var previous = []; 106 | final u8prk = Uint8List.fromList(prk); 107 | 108 | for (var i = 1; i <= n; i++) { 109 | final hmacSha256 = HMAC_SHA256_64..init(KeyParameter(u8prk)); 110 | final data = Uint8List.fromList([ 111 | ...previous, 112 | ...info, 113 | i, 114 | ]); 115 | previous = hmacSha256.process(data); 116 | okm.addAll(previous); 117 | } 118 | return Uint8List.fromList(okm.sublist(0, length)); 119 | } 120 | 121 | List unpad(List padded) { 122 | final int unpaddedLen = (padded[0] << 8) + padded[1]; 123 | if (unpaddedLen == 0 || unpaddedLen > padded.length - 2) { 124 | throw Exception('Invalid padding'); 125 | } 126 | return padded.sublist(2, 2 + unpaddedLen); 127 | } 128 | 129 | List calculateMac(List key, List nonce, List ciphertext) { 130 | final u8key = Uint8List.fromList(key); 131 | final hmacSha256 = HMAC_SHA256_64..init(KeyParameter(u8key)); 132 | return hmacSha256.process(Uint8List.fromList([...nonce, ...ciphertext])); 133 | } 134 | 135 | Map parsePayload(String payload) { 136 | if (payload.isEmpty || payload[0] == '#') { 137 | throw Exception('Unknown version'); 138 | } 139 | 140 | if (payload.length < 132 || payload.length > 87472) { 141 | throw Exception('Invalid payload size'); 142 | } 143 | 144 | final data = base64.decode(payload); 145 | 146 | if (data[0] != 0x02) { 147 | throw Exception('Unsupported version'); 148 | } 149 | 150 | final nonce = data.sublist(1, 33); 151 | final mac = data.sublist(data.length - 32); 152 | final ciphertext = data.sublist(33, data.length - 32); 153 | 154 | return { 155 | 'nonce': nonce, 156 | 'ciphertext': ciphertext, 157 | 'mac': mac, 158 | }; 159 | } 160 | 161 | void verifyMac( 162 | List hmacKey, 163 | List nonce, 164 | List ciphertext, 165 | List mac, 166 | ) { 167 | final calculatedMac = calculateMac(hmacKey, nonce, ciphertext); 168 | if (hex.encode(calculatedMac) != hex.encode(mac)) { 169 | throw Exception('Invalid MAC'); 170 | } 171 | } 172 | 173 | String checkPublicKey(String publicKey) { 174 | if (publicKey.length == 66 && 175 | (publicKey.startsWith('02') || publicKey.startsWith('03'))) { 176 | return publicKey; 177 | } else if (publicKey.length > 66 && publicKey.startsWith('04')) { 178 | return publicKey; 179 | } else if (publicKey.length == 64) { 180 | return '02$publicKey'; 181 | } 182 | throw Exception('Invalid Public Key'); 183 | } 184 | -------------------------------------------------------------------------------- /lib/src/nips/nip_051.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'package:nostr/nostr.dart'; 3 | import 'package:nostr/src/crypto/nip_004.dart'; 4 | 5 | /// Lists 6 | class Nip51 { 7 | static List> peoplesToTags(List items) { 8 | final List> result = []; 9 | for (final People item in items) { 10 | result.add([ 11 | "p", 12 | item.pubkey, 13 | item.mainRelay ?? "", 14 | item.petName ?? "", 15 | item.aliasPubKey ?? "", 16 | ]); 17 | } 18 | return result; 19 | } 20 | 21 | static List> bookmarksToTags(List items) { 22 | final List> result = []; 23 | for (final String item in items) { 24 | result.add(["e", item]); 25 | } 26 | return result; 27 | } 28 | 29 | static String peoplesToContent( 30 | List items, String privkey, String pubkey) { 31 | final list = []; 32 | for (final item in items) { 33 | list.add([ 34 | 'p', 35 | item.pubkey, 36 | item.mainRelay ?? "", 37 | item.petName ?? "", 38 | item.aliasPubKey ?? "", 39 | ]); 40 | } 41 | final String content = json.encode(list); 42 | return nip4cipher(privkey, '02$pubkey', content, cipher: true); 43 | } 44 | 45 | static String bookmarksToContent( 46 | List items, 47 | String privkey, 48 | String pubkey, 49 | ) { 50 | final list = []; 51 | for (final item in items) { 52 | list.add(['e', item]); 53 | } 54 | final String content = json.encode(list); 55 | return nip4cipher(privkey, '02$pubkey', content, cipher: true); 56 | } 57 | 58 | static Map fromContent( 59 | String content, String privkey, String pubkey) { 60 | final List people = []; 61 | final List bookmarks = []; 62 | final int ivIndex = content.indexOf("?iv="); 63 | if (ivIndex <= 0) { 64 | throw Exception("Invalid content, could not get ivIndex: $content"); 65 | } 66 | final String iv = 67 | content.substring(ivIndex + "?iv=".length, content.length); 68 | final String encString = content.substring(0, ivIndex); 69 | final String deContent = 70 | nip4cipher(privkey, "02$pubkey", encString, cipher: false, nonce: iv); 71 | for (final List tag in json.decode(deContent)) { 72 | if (tag[0] == "p") { 73 | people.add(People(tag[1], tag.length > 2 ? tag[2] : "", 74 | tag.length > 3 ? tag[3] : "", tag.length > 4 ? tag[4] : "")); 75 | } else if (tag[0] == "e") { 76 | // bookmark 77 | bookmarks.add(tag[1]); 78 | } 79 | } 80 | return {"people": people, "bookmarks": bookmarks}; 81 | } 82 | 83 | static Event createMutePeople( 84 | List items, 85 | List encryptedItems, 86 | String privkey, 87 | String pubkey, 88 | ) { 89 | return Event.from( 90 | kind: 10000, 91 | tags: peoplesToTags(items), 92 | content: peoplesToContent(encryptedItems, privkey, pubkey), 93 | privkey: privkey, 94 | ); 95 | } 96 | 97 | static Event createPinEvent(List items, List encryptedItems, 98 | String privkey, String pubkey) { 99 | return Event.from( 100 | kind: 10001, 101 | tags: bookmarksToTags(items), 102 | content: bookmarksToContent(encryptedItems, privkey, pubkey), 103 | privkey: privkey); 104 | } 105 | 106 | static Event createCategorizedPeople(String identifier, List items, 107 | List encryptedItems, String privkey, String pubkey) { 108 | final List> tags = peoplesToTags(items); 109 | tags.add(["d", identifier]); 110 | return Event.from( 111 | kind: 30000, 112 | tags: tags, 113 | content: peoplesToContent(encryptedItems, privkey, pubkey), 114 | privkey: privkey); 115 | } 116 | 117 | static Event createCategorizedBookmarks(String identifier, List items, 118 | List encryptedItems, String privkey, String pubkey) { 119 | final List> tags = bookmarksToTags(items); 120 | tags.add(["d", identifier]); 121 | return Event.from( 122 | kind: 30001, 123 | tags: tags, 124 | content: bookmarksToContent(encryptedItems, privkey, pubkey), 125 | privkey: privkey); 126 | } 127 | 128 | static Lists getLists(Event event, String privkey) { 129 | if (event.kind != 10000 && 130 | event.kind != 10001 && 131 | event.kind != 30000 && 132 | event.kind != 30001) { 133 | throw Exception("${event.kind} is not nip51 compatible"); 134 | } 135 | String identifier = ""; 136 | final List people = []; 137 | final List bookmarks = []; 138 | for (final List tag in event.tags) { 139 | if (tag[0] == "p") { 140 | people.add(People(tag[1], tag.length > 2 ? tag[2] : "", 141 | tag.length > 3 ? tag[3] : "", tag.length > 4 ? tag[4] : "")); 142 | } 143 | if (tag[0] == "e") { 144 | bookmarks.add(tag[1]); 145 | } 146 | if (tag[0] == "d") identifier = tag[1]; 147 | } 148 | final pubkey = Keys(privkey).public; 149 | final Map content = Nip51.fromContent(event.content, privkey, pubkey); 150 | people.addAll(content["people"]); 151 | bookmarks.addAll(content["bookmarks"]); 152 | if (event.kind == 10000) identifier = "Mute"; 153 | if (event.kind == 10001) identifier = "Pin"; 154 | 155 | return Lists(event.pubkey, identifier, people, bookmarks); 156 | } 157 | } 158 | 159 | /// 160 | class People { 161 | String pubkey; 162 | String? mainRelay; 163 | String? petName; 164 | String? aliasPubKey; 165 | 166 | /// Default constructor 167 | People(this.pubkey, this.mainRelay, this.petName, this.aliasPubKey); 168 | } 169 | 170 | class Lists { 171 | String owner; 172 | 173 | String identifier; 174 | 175 | List people; 176 | 177 | List bookmarks; 178 | 179 | /// Default constructor 180 | Lists(this.owner, this.identifier, this.people, this.bookmarks); 181 | } 182 | -------------------------------------------------------------------------------- /lib/src/nips/nip_059.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'package:nostr/nostr.dart'; 3 | 4 | /// NIP-59 "Gift Wrap" using the two-layer approach: 5 | /// Rumor (unsigned) -> Seal (kind=13) -> GiftWrap (kind=1059). 6 | /// 7 | /// The "rumor" is any content you want to hide, with no signature. The "seal" is 8 | /// signed by the real author, but only says "kind=13" with ciphertext in .content. 9 | /// The "gift wrap" is signed by an ephemeral key, `kind=1059`. 10 | /// 11 | /// Decryption by the recipient: 12 | /// 1) unwrap gift => returns the "seal" event 13 | /// 2) unseal => returns the original rumor 14 | class Nip59 { 15 | /// Create a full "gift wrap" (kind=1059) that hides an underlying rumor. 16 | /// 17 | /// `rumor` A Nostr event without a signature. If it has .sig or .id, 18 | /// we will forcibly remove them to preserve "unsigned rumor." 19 | /// 20 | /// `authorPrivkey` The real author's secret key (hex-encoded, 32 bytes). 21 | /// 22 | /// `recipientPubkey` The final recipient's pubkey (hex-encoded, 32 bytes). 23 | /// 24 | /// `ephemeralPrivkey` Optionally specify the ephemeral key used for the gift wrap. 25 | /// If null, it is randomly generated. 26 | /// 27 | /// `createdAt` Optionally override 'created_at' for the final gift wrap. 28 | /// Timestamps can be randomized to defeat time analysis. 29 | /// 30 | /// `extraTags` Additional tags to store inside the final gift wrap (e.g. expiration). 31 | /// 32 | /// Returns a `kind=1059` event that you broadcast. The seal is inside the `content`. 33 | static Future wrap({ 34 | required Event rumor, 35 | required String authorPrivkey, 36 | required String recipientPubkey, 37 | String? ephemeralPrivkey, 38 | int? createdAt, 39 | List>? extraTags, 40 | }) async { 41 | final authorPubkey = Keys(authorPrivkey).public; 42 | 43 | if (rumor.pubkey != authorPubkey) { 44 | throw Exception( 45 | "Beware impersonation: The seal pubkey doesn't match the rumor pubkey", 46 | ); 47 | } 48 | 49 | // if 'rumor' is already signed, let's forcibly remove the signature & id: 50 | // Copy without "id" and "sig" to ensure it's an unsigned rumor: 51 | final unsignedRumor = Event.partial( 52 | pubkey: authorPubkey, 53 | createdAt: rumor.createdAt, 54 | kind: rumor.kind, 55 | tags: rumor.tags, 56 | content: rumor.content, 57 | ); 58 | 59 | final rumorJson = unsignedRumor.toJson(); 60 | 61 | // Encrypt rumor with (authorPrivkey, recipientPubkey) 62 | final sealCiphertext = await Nip44.encrypt( 63 | plaintext: rumorJson, 64 | recipientPublicKey: recipientPubkey, 65 | senderSecretKey: authorPrivkey, 66 | ); 67 | 68 | // Build the seal event (kind=13, empty tags, .content = ciphertext) 69 | // "tags must always be empty for kind=13" per the spec. 70 | // This event is signed by real author. 71 | final seal = Event.from( 72 | kind: 13, 73 | tags: [], // Per NIP-59, MUST always be empty 74 | content: sealCiphertext, 75 | pubkey: authorPubkey, 76 | privkey: authorPrivkey, 77 | createdAt: _randomPastTimestamp(), 78 | ); 79 | 80 | // Create a "gift wrap" (kind=1059) by encrypting the seal using an ephemeral key. 81 | // Then sign with ephemeral key. If ephemeral key not specified, generate it 82 | final ephemeral = ephemeralPrivkey ?? Keys.generate().secret; 83 | final ephemeralPubkey = Keys(ephemeral).public; 84 | 85 | // Encrypt seal with (ephemeralPriv, recipientPubkey) 86 | final wrapCiphertext = await Nip44.encrypt( 87 | plaintext: seal.toJson(), 88 | recipientPublicKey: recipientPubkey, 89 | senderSecretKey: ephemeral, 90 | ); 91 | 92 | // Build gift wrap event (kind=1059). Typically includes ["p", recipient] in tags 93 | final tags = [ 94 | ["p", recipientPubkey], 95 | if (extraTags != null) ...extraTags, 96 | ]; 97 | 98 | final giftWrap = Event.from( 99 | kind: 1059, 100 | tags: tags, 101 | content: wrapCiphertext, 102 | pubkey: ephemeralPubkey, 103 | privkey: ephemeral, // ephemeral signing key 104 | createdAt: createdAt ?? _randomPastTimestamp(), 105 | ); 106 | 107 | // You only broadcast the final giftWrap to the network. The rumor and seal 108 | // remain local or ephemeral. 109 | return giftWrap; 110 | } 111 | 112 | /// Unwrap a gift-wrapped event (`kind=1059`) to recover the sealed rumor (`kind=13`), 113 | /// then decrypt that seal to get the underlying rumor. 114 | /// 115 | /// `giftWrap` must be a `kind=1059` event posted by ephemeral key. 116 | /// `recipientPrivkey` is the real recipient's secret key. 117 | /// 118 | /// Returns the final "rumor" (an **unsigned** event), which you can parse or show. 119 | static Future unwrap({ 120 | required Event giftWrap, 121 | required String recipientPrivkey, 122 | }) async { 123 | if (giftWrap.kind != 1059) { 124 | throw Exception('Not a gift wrap event (expected kind=1059)'); 125 | } 126 | 127 | // Decrypt the gift wrap to recover the "seal" (kind=13) 128 | // with (ephemeralPub = giftWrap.pubkey, recipientPrivkey) 129 | final sealJsonStr = await Nip44.decrypt( 130 | payload: giftWrap.content, 131 | senderPublicKey: giftWrap.pubkey, 132 | recipientSecretKey: recipientPrivkey, 133 | ); 134 | 135 | // Reconstruct the seal event 136 | final seal = Event.fromJson(sealJsonStr); 137 | 138 | if (seal.kind != 13) { 139 | throw Exception('Unwrapped content is not a seal (expected kind=13)'); 140 | } 141 | 142 | // Decrypt the seal to recover the rumor 143 | // with (authorPub = seal.pubkey, recipientPrivkey) 144 | final rumorJsonStr = await Nip44.decrypt( 145 | payload: seal.content, 146 | senderPublicKey: seal.pubkey, 147 | recipientSecretKey: recipientPrivkey, 148 | ); 149 | 150 | final rumorMap = json.decode(rumorJsonStr) as Map; 151 | final pubkey = getRequiredField(rumorMap, 'pubkey'); 152 | final createdAt = getRequiredField(rumorMap, 'created_at'); 153 | final kind = getRequiredField(rumorMap, 'kind'); 154 | final content = getRequiredField(rumorMap, 'content'); 155 | final rawTags = getRequiredField(rumorMap, 'tags'); 156 | final tags = rawTags 157 | .map((e) => (e as List).map((e) => e as String).toList()) 158 | .toList(); 159 | 160 | final rumor = Event.partial( 161 | pubkey: pubkey, 162 | createdAt: createdAt, 163 | kind: kind, 164 | content: content, 165 | tags: tags, 166 | ); 167 | 168 | if (seal.pubkey != rumor.pubkey) { 169 | throw Exception( 170 | "Beware impersonation: The seal pubkey doesn't match the rumor pubkey", 171 | ); 172 | } 173 | 174 | if (rumor.sig.isNotEmpty) { 175 | // If it is signed, the message might leak to relays and become fully public. 176 | throw Exception('Rumor should be unsigned'); 177 | } 178 | 179 | // The rumor is intentionally unsigned per NIP-59. It can be any kind of event, but .sig is empty. Return it: 180 | return rumor; 181 | } 182 | 183 | /// Timestamps SHOULD be in the past (two days) 184 | static int _randomPastTimestamp() { 185 | final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; 186 | final offset = DateTime.now().millisecondsSinceEpoch % (2 * 24 * 3600); 187 | return now - (offset ~/ 1000); 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /lib/src/request.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:nostr/src/filter.dart'; 4 | 5 | /// Used to request events and subscribe to new updates. 6 | class Request { 7 | /// subscription_id is a random string that should be used to represent a subscription. 8 | late String subscriptionId; 9 | 10 | /// filters is a JSON object that determines what events will be sent in that subscription 11 | late List filters; 12 | 13 | Request({required this.subscriptionId, this.filters = const []}); 14 | 15 | /// Serialize to nostr request message 16 | /// - ["REQ", subscription_id, filter JSON, filter JSON, ...] 17 | String serialize() { 18 | final theFilters = filters.map((item) => item.toJson()).toList(); 19 | 20 | return json.encode( 21 | [ 22 | "REQ", 23 | subscriptionId, 24 | if (filters.isEmpty) Filter() else ...theFilters, 25 | ], 26 | ); 27 | } 28 | 29 | /// Deserialize a nostr request message 30 | /// - '["REQ", subscriptionId, filter JSON, filter JSON, ...]' 31 | Request.deserialize(String payload) { 32 | final data = json.decode(payload); 33 | 34 | // Ensure we have at least ["REQ", ] 35 | if (data.length < 2) { 36 | throw Exception('Message too short to be a REQ message'); 37 | } 38 | 39 | if (data[0] != "REQ") { 40 | throw Exception('Not a REQ message (first element must be "REQ")'); 41 | } 42 | 43 | subscriptionId = data[1]; 44 | filters = []; 45 | 46 | // Remaining items (from index 2 onward) are filters 47 | if (data.length > 2) { 48 | for (var i = 2; i < data.length; i++) { 49 | filters.add(Filter.fromJson(data[i])); 50 | } 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /lib/src/schnorr.dart: -------------------------------------------------------------------------------- 1 | import 'package:bip340/bip340.dart' as bip340; 2 | import 'package:convert/convert.dart'; 3 | import 'package:nostr/nostr.dart'; 4 | 5 | class Schnorr { 6 | /// Encapsulate dart-bip340 sign() so you don't need to add bip340 as a dependency 7 | static String sign({ 8 | required String secretKey, 9 | required String message, 10 | String? aux, 11 | }) { 12 | aux ??= generate64RandomHexChars(); 13 | 14 | if (hex.decode(secretKey).length != 32) { 15 | throw Exception("secretKey must be 32-bytes hex encoded"); 16 | } 17 | if (hex.decode(message).length != 32) { 18 | throw Exception( 19 | "message must be 32-bytes hex encoded (a hash of the actual message)", 20 | ); 21 | } 22 | if (hex.decode(aux).length != 32) { 23 | throw Exception("aux must be 32-bytes hex encoded"); 24 | } 25 | 26 | return bip340.sign(secretKey, message, aux); 27 | } 28 | 29 | /// Encapsulate dart-bip340 verify() so you don't need to add bip340 as a dependency 30 | static bool verify({ 31 | required String publicKey, 32 | required String message, 33 | required String signature, 34 | }) { 35 | if (hex.decode(publicKey).length != 32) { 36 | throw Exception("publicKey must be 32-bytes hex encoded"); 37 | } 38 | if (hex.decode(message).length != 32) { 39 | throw Exception( 40 | "message must be 32-bytes hex encoded (a hash of the actual message)", 41 | ); 42 | } 43 | if (hex.decode(signature).length != 64) { 44 | throw Exception("signature must be 64-bytes hex encoded"); 45 | } 46 | return bip340.verify(publicKey, message, signature); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /lib/src/utils.dart: -------------------------------------------------------------------------------- 1 | import 'dart:math'; 2 | import 'dart:typed_data'; 3 | 4 | import 'package:convert/convert.dart'; 5 | import 'package:pointycastle/export.dart'; 6 | 7 | /// generates 32 random bytes converted in hex 8 | String generate64RandomHexChars() { 9 | final randomBytes = generateRandomBytes(32); 10 | return hex.encode(randomBytes); 11 | } 12 | 13 | /// current unix timestamp in seconds 14 | int currentUnixTimestampSeconds() { 15 | return DateTime.now().millisecondsSinceEpoch ~/ 1000; 16 | } 17 | 18 | /// generates the requested quantity of random secure bytes 19 | List generateRandomBytes(int quantity) { 20 | final random = Random.secure(); 21 | return List.generate(quantity, (i) => random.nextInt(256)); 22 | } 23 | 24 | T getRequiredField(Map map, String field) { 25 | if (!map.containsKey(field) || map[field] == null) { 26 | throw Exception("Missing required field '$field'."); 27 | } 28 | if (map[field] is! T) { 29 | throw Exception("Field '$field' should be of type $T."); 30 | } 31 | return map[field] as T; 32 | } 33 | 34 | List sha256(List bytes) { 35 | final hash = SHA256Digest().process(Uint8List.fromList(bytes)); 36 | return hash; 37 | } 38 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: nostr 2 | description: A library for nostr protocol implemented in dart for flutter 3 | version: 1.5.0 4 | homepage: https://github.com/ethicnology/dart-nostr 5 | 6 | environment: 7 | sdk: '>=3.0.0 <4.0.0' 8 | 9 | dev_dependencies: 10 | lints: ^5.1.1 11 | test: ^1.16.0 12 | dependencies: 13 | bech32: ^0.2.2 14 | bip340: ^0.3.0 15 | convert: ^3.1.1 16 | elliptic: ^0.3.11 17 | pointycastle: ^3.7.3 18 | -------------------------------------------------------------------------------- /test/close_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:nostr/nostr.dart'; 4 | import 'package:test/test.dart'; 5 | 6 | void main() { 7 | group('Close', () { 8 | test('Constructor', () { 9 | final subscriptionId = generate64RandomHexChars(); 10 | final close = Close(subscriptionId); 11 | expect(close.subscriptionId, subscriptionId); 12 | }); 13 | 14 | test('Close.serialize', () { 15 | final subscriptionId = generate64RandomHexChars(); 16 | final serialized = '["CLOSE","$subscriptionId"]'; 17 | final close = Close(subscriptionId); 18 | expect(close.serialize(), serialized); 19 | }); 20 | 21 | test('Request.deserialize', () { 22 | final subscriptionId = generate64RandomHexChars(); 23 | final serialized = json.encode(["CLOSE", subscriptionId]); 24 | final close = Close.deserialize(serialized); 25 | expect(close.subscriptionId, subscriptionId); 26 | }); 27 | }); 28 | } 29 | -------------------------------------------------------------------------------- /test/eose_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:nostr/nostr.dart'; 4 | import 'package:test/test.dart'; 5 | 6 | void main() { 7 | group('Eose', () { 8 | test('Constructor', () { 9 | final String subscriptionId = generate64RandomHexChars(); 10 | final eose = Eose(subscriptionId); 11 | expect(eose.subscriptionId, subscriptionId); 12 | }); 13 | 14 | test('Eose.serialize', () { 15 | final String subscriptionId = generate64RandomHexChars(); 16 | final String serialized = '["EOSE","$subscriptionId"]'; 17 | final eose = Eose(subscriptionId); 18 | expect(eose.serialize(), serialized); 19 | }); 20 | 21 | test('Eose.deserialize', () { 22 | final String subscriptionId = generate64RandomHexChars(); 23 | final serialized = json.encode(["EOSE", subscriptionId]); 24 | final eose = Eose.deserialize(serialized); 25 | expect(eose.subscriptionId, subscriptionId); 26 | }); 27 | }); 28 | } 29 | -------------------------------------------------------------------------------- /test/event_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:bip340/bip340.dart'; 4 | import 'package:nostr/nostr.dart'; 5 | import 'package:test/test.dart'; 6 | 7 | void main() { 8 | group('Event', () { 9 | test('Default constructor', () { 10 | const String id = 11 | "4b697394206581b03ca5222b37449a9cdca1741b122d78defc177444e2536f49"; 12 | const String pubKey = 13 | "981cc2078af05b62ee1f98cff325aac755bf5c5836a265c254447b5933c6223b"; 14 | const int createdAt = 1672175320; 15 | const int kind = 1; 16 | final List> tags = []; 17 | const String content = "Ceci est une analyse du websocket"; 18 | const String sig = 19 | "797c47bef50eff748b8af0f38edcb390facf664b2367d72eb71c50b5f37bc83c4ae9cc9007e8489f5f63c66a66e101fd1515d0a846385953f5f837efb9afe885"; 20 | 21 | final Event event = Event( 22 | id, 23 | pubKey, 24 | createdAt, 25 | kind, 26 | tags, 27 | content, 28 | sig, 29 | ); 30 | 31 | expect(event.id, id); 32 | expect(event.pubkey, pubKey); 33 | expect(event.createdAt, createdAt); 34 | expect(event.kind, kind); 35 | expect(event.tags, tags); 36 | expect(event.content, content); 37 | expect(event.sig, sig); 38 | expect(event.subscriptionId, null); 39 | }); 40 | 41 | test('Constructor.from', () { 42 | const int createdAt = 1672175320; 43 | const int kind = 1; 44 | final List> tags = []; 45 | const String content = "Ceci est une analyse du websocket"; 46 | const String privkey = 47 | "5ee1c8000ab28edd64d74a7d951ac2dd559814887b1b9e1ac7c5f89e96125c12"; 48 | 49 | final Event event = Event.from( 50 | createdAt: createdAt, 51 | kind: kind, 52 | tags: tags, 53 | content: content, 54 | privkey: privkey, 55 | ); 56 | 57 | expect( 58 | event.id, 59 | "4b697394206581b03ca5222b37449a9cdca1741b122d78defc177444e2536f49", 60 | ); 61 | expect( 62 | event.pubkey, 63 | "981cc2078af05b62ee1f98cff325aac755bf5c5836a265c254447b5933c6223b", 64 | ); 65 | expect(event.createdAt, createdAt); 66 | expect(event.kind, kind); 67 | expect(event.tags, tags); 68 | expect(event.content, content); 69 | expect(verify(event.pubkey, event.id, event.sig), true); 70 | }); 71 | 72 | test('Constructor.from generated createdAt', () { 73 | final Event event = Event.from( 74 | kind: 1, 75 | tags: [], 76 | content: "", 77 | privkey: 78 | "5ee1c8000ab28edd64d74a7d951ac2dd559814887b1b9e1ac7c5f89e96125c12", 79 | ); 80 | expect(event.createdAt != 0, isTrue); 81 | }); 82 | 83 | test('Constructor.fromMap', () { 84 | final json = { 85 | "kind": 1, 86 | "pubkey": 87 | "0ba0206887bd61579bf65ec09d7806bea32c64be1cf2c978cf031a811cd238db", 88 | "content": "dart-nostr", 89 | "tags": [], 90 | "created_at": 1672477962, 91 | "sig": 92 | "246970954e7b74e7fe381a4c818fed739ee59444cb536dadf45fbbce33bd7455ae7cd678c347c4a0c6e0a4483d18c7e26b7abe76f4cc73234f774e0e0d65204b", 93 | "id": "047663d895d56aefa3f528935c7ce7dc8939eb721a0ec76ef2e558a8257955d2" 94 | }; 95 | final event = Event.fromMap(json); 96 | expect(event.id, json['id']); 97 | expect(event.pubkey, json['pubkey']); 98 | expect(event.createdAt, json['created_at']); 99 | expect(event.kind, json['kind']); 100 | expect(event.tags, json['tags']); 101 | expect(event.content, json['content']); 102 | expect(event.sig, json['sig']); 103 | }); 104 | 105 | test('Constructor.fromJson –> toJson', () { 106 | const json = 107 | '{"id":"f9b8c5b7a8692b0f5b8ca9f2c29ff84d6baa5a60d14cbf1c54bd2bb77ee8b41f","pubkey":"891b945271cd3c65dc22cb9e77ba08f5cd165ad8d9fba370b740f7db95f98b10","created_at":1675015139,"kind":1,"tags":[["e","343ed9c6ca7a0a8f33f8cfed04b6cea4a4dda50a649daffaf85d6410507c5c7c","wss://relay.damus.io","reply"],["p","7b6461d02c6f0be1cacdcf968c4246105a2db51c7770993bf8bb25e59cedffa7"]],"content":"Authorize just this time...I have commitment issues.","sig":"bd63b762379bd06e536ccb943f909f075bd512315fbf2407be19f03ee9d3ef5b4a70205aa7a8e68cb8c2d250f56ef8f5074339abd741d32dbd18d16641a339ef"}'; 108 | final event = Event.fromJson(json); 109 | expect(event.toJson(), json); 110 | }); 111 | }); 112 | 113 | test('Constructor.serialize', () { 114 | final serialized = json.encode([ 115 | "EVENT", 116 | { 117 | "id": 118 | "047663d895d56aefa3f528935c7ce7dc8939eb721a0ec76ef2e558a8257955d2", 119 | "pubkey": 120 | "0ba0206887bd61579bf65ec09d7806bea32c64be1cf2c978cf031a811cd238db", 121 | "created_at": 1672477962, 122 | "kind": 1, 123 | "tags": [], 124 | "content": "dart-nostr", 125 | "sig": 126 | "246970954e7b74e7fe381a4c818fed739ee59444cb536dadf45fbbce33bd7455ae7cd678c347c4a0c6e0a4483d18c7e26b7abe76f4cc73234f774e0e0d65204b", 127 | } 128 | ]); 129 | final serializedWithSubscriptionId = json.encode([ 130 | "EVENT", 131 | "subscription_id", 132 | { 133 | "id": 134 | "047663d895d56aefa3f528935c7ce7dc8939eb721a0ec76ef2e558a8257955d2", 135 | "pubkey": 136 | "0ba0206887bd61579bf65ec09d7806bea32c64be1cf2c978cf031a811cd238db", 137 | "created_at": 1672477962, 138 | "kind": 1, 139 | "tags": [], 140 | "content": "dart-nostr", 141 | "sig": 142 | "246970954e7b74e7fe381a4c818fed739ee59444cb536dadf45fbbce33bd7455ae7cd678c347c4a0c6e0a4483d18c7e26b7abe76f4cc73234f774e0e0d65204b", 143 | } 144 | ]); 145 | 146 | final event = Event.deserialize(serialized); 147 | expect(event.serialize(), serialized); 148 | final eventWithSubscriptionId = 149 | Event.deserialize(serializedWithSubscriptionId); 150 | expect( 151 | eventWithSubscriptionId.serialize(), 152 | serializedWithSubscriptionId, 153 | ); 154 | }); 155 | 156 | test('Constructor.deserialize', () { 157 | final serialized = [ 158 | "EVENT", 159 | "0954524188078879", 160 | { 161 | "id": 162 | "f9b8c5b7a8692b0f5b8ca9f2c29ff84d6baa5a60d14cbf1c54bd2bb77ee8b41f", 163 | "kind": 1, 164 | "pubkey": 165 | "891b945271cd3c65dc22cb9e77ba08f5cd165ad8d9fba370b740f7db95f98b10", 166 | "created_at": 1675015139, 167 | "content": "Authorize just this time...I have commitment issues.", 168 | "tags": [ 169 | [ 170 | "e", 171 | "343ed9c6ca7a0a8f33f8cfed04b6cea4a4dda50a649daffaf85d6410507c5c7c", 172 | "wss://relay.damus.io", 173 | "reply" 174 | ], 175 | [ 176 | "p", 177 | "7b6461d02c6f0be1cacdcf968c4246105a2db51c7770993bf8bb25e59cedffa7" 178 | ] 179 | ], 180 | "sig": 181 | "bd63b762379bd06e536ccb943f909f075bd512315fbf2407be19f03ee9d3ef5b4a70205aa7a8e68cb8c2d250f56ef8f5074339abd741d32dbd18d16641a339ef" 182 | } 183 | ]; 184 | final event = Event.deserialize(json.encode(serialized)); 185 | expect(event.subscriptionId, serialized[1]); 186 | final serializedEvent = serialized[2] as Map; 187 | expect(event.id, serializedEvent['id']); 188 | expect(event.pubkey, serializedEvent['pubkey']); 189 | expect(event.createdAt, serializedEvent['created_at']); 190 | expect(event.kind, serializedEvent['kind']); 191 | expect(event.tags, serializedEvent['tags']); 192 | expect(event.content, serializedEvent['content']); 193 | expect(event.sig, serializedEvent['sig']); 194 | 195 | final serializeWithoutSubscriptionId = json.encode([ 196 | "EVENT", 197 | { 198 | "id": 199 | "67bd60e47d7fdddadebff890143167bcd7b5d28b2c3008eae40e0ac5ba0e6b34", 200 | "kind": 1, 201 | "pubkey": 202 | "36685fa5106b1bc03ae7bea82eded855d8f56c41db4c8bdef8099e1e0f2b2afa", 203 | "created_at": 1674403511, 204 | "content": 205 | r"Block 773103 was just confirmed. The total value of all the non-coinbase outputs was 61,549,183,849 sats, or $14,025,828", 206 | "tags": [], 207 | "sig": 208 | "4912a6850a711a876fd2443771f69e094041f7e832df65646a75c2c77989480cce9b41aa5ea3d055c16fe5beb7d11d3d5fa29b4c4046c150b09393c4d3d16eb4" 209 | } 210 | ]); 211 | final eventWithoutSubscriptionId = 212 | Event.deserialize(serializeWithoutSubscriptionId); 213 | expect(eventWithoutSubscriptionId.subscriptionId, null); 214 | }); 215 | 216 | test('Fake event (verify=false) with empty tag', () { 217 | const json = 218 | '{"kind": 1, "pubkey":"0ba0206887bd61579bf65ec09d7806bea32c64be1cf2c978cf031a811cd238db","content": "dart-nostr","tags": [["p","052acd328f1c1d48e86fff3e34ada4bfc60578116f4f68f296602530529656a2",""]],"created_at": 1672477962,"sig":"246970954e7b74e7fe381a4c818fed739ee59444cb536dadf45fbbce33bd7455ae7cd678c347c4a0c6e0a4483d18c7e26b7abe76f4cc73234f774e0e0d65204b","id": "047663d895d56aefa3f528935c7ce7dc8939eb721a0ec76ef2e558a8257955d2"}'; 219 | final event = Event.fromJson(json, verify: false); 220 | expect(event.tags[0][2], equals("")); 221 | }); 222 | 223 | test('Event.deserialize throw', () { 224 | expect(() => Event.deserialize(json.encode([])), throwsException); 225 | }); 226 | 227 | test('Event.partial', () { 228 | final emptyEvent = Event.partial(); 229 | expect(emptyEvent.isValid(), false); 230 | emptyEvent.createdAt = currentUnixTimestampSeconds(); 231 | emptyEvent.pubkey = 232 | "981cc2078af05b62ee1f98cff325aac755bf5c5836a265c254447b5933c6223b"; 233 | emptyEvent.id = emptyEvent.getEventId(); 234 | emptyEvent.sig = emptyEvent.getSignature( 235 | "5ee1c8000ab28edd64d74a7d951ac2dd559814887b1b9e1ac7c5f89e96125c12", 236 | ); 237 | expect(emptyEvent.isValid(), true); 238 | }); 239 | } 240 | -------------------------------------------------------------------------------- /test/filter_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:nostr/src/filter.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | void main() { 5 | group('Filter', () { 6 | test('Default constructor', () { 7 | final List ids = [ 8 | "047663d895d56aefa3f528935c7ce7dc8939eb721a0ec76ef2e558a8257955d2" 9 | ]; 10 | final List authors = [ 11 | "0ba0206887bd61579bf65ec09d7806bea32c64be1cf2c978cf031a811cd238db" 12 | ]; 13 | final List kinds = [0, 1, 2, 7]; 14 | final List e = []; 15 | final List a = []; 16 | final List p = []; 17 | const int since = 1672477960; 18 | const int until = 1674063680; 19 | const int limit = 450; 20 | const String search = "term"; 21 | 22 | final Filter filter = Filter( 23 | ids: ids, 24 | authors: authors, 25 | kinds: kinds, 26 | e: e, 27 | a: a, 28 | p: p, 29 | since: since, 30 | until: until, 31 | limit: limit, 32 | search: search, 33 | ); 34 | 35 | expect(filter.ids, ids); 36 | expect(filter.authors, authors); 37 | expect(filter.kinds, kinds); 38 | expect(filter.e, e); 39 | expect(filter.a, a); 40 | expect(filter.p, p); 41 | expect(filter.since, since); 42 | expect(filter.until, until); 43 | expect(filter.limit, limit); 44 | expect(filter.search, search); 45 | }); 46 | 47 | test('Constructor.fromJson', () { 48 | final json = { 49 | "ids": [ 50 | "047663d895d56aefa3f528935c7ce7dc8939eb721a0ec76ef2e558a8257955d2" 51 | ], 52 | "authors": [ 53 | "0ba0206887bd61579bf65ec09d7806bea32c64be1cf2c978cf031a811cd238db" 54 | ], 55 | "kinds": [0, 1, 2, 7], 56 | "#e": [], 57 | "#a": [], 58 | "#p": [], 59 | "since": 1672477960, 60 | "until": 1674063680, 61 | "limit": 450, 62 | "search": "test", 63 | }; 64 | 65 | final Filter filter = Filter.fromJson(json); 66 | expect(filter.ids, json['ids']); 67 | expect(filter.authors, json['authors']); 68 | expect(filter.kinds, json['kinds']); 69 | expect(filter.e, json['#e']); 70 | expect(filter.a, json['#a']); 71 | expect(filter.p, json['#p']); 72 | expect(filter.since, json['since']); 73 | expect(filter.until, json['until']); 74 | expect(filter.limit, json['limit']); 75 | expect(filter.search, json['search']); 76 | }); 77 | }); 78 | } 79 | -------------------------------------------------------------------------------- /test/keys_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:nostr/nostr.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | void main() { 5 | group('Keys', () { 6 | test('Default constructor', () { 7 | const hex = 8 | "5ee1c8000ab28edd64d74a7d951ac2dd559814887b1b9e1ac7c5f89e96125c12"; 9 | final keys = Keys(hex); 10 | expect(keys.secret, hex); 11 | expect( 12 | keys.public, 13 | "981cc2078af05b62ee1f98cff325aac755bf5c5836a265c254447b5933c6223b", 14 | ); 15 | }); 16 | 17 | test('Keys from NIP19 nsec', () { 18 | const nsec = 19 | "nsec1tmsusqq2k28d6exhff7e2xkzm42es9yg0vdeuxk8chufa9sjtsfq8z3spp"; 20 | final keys = Keys.from(secretKey: nsec); 21 | expect(keys.secret, 22 | '5ee1c8000ab28edd64d74a7d951ac2dd559814887b1b9e1ac7c5f89e96125c12'); 23 | }); 24 | 25 | test('Keys with invalid encoding (not HEX or Bech32)', () { 26 | expect( 27 | () => Keys( 28 | 'zz7daa0537b93aa3ae4495a274ecc05077e3dc168809d77a7afa4ec1db0fb3bd'), 29 | throwsException, 30 | ); 31 | }); 32 | 33 | test('Keys with invalid secret key (secret.length != 64)', () { 34 | expect( 35 | () => Keys( 36 | "", 37 | ), 38 | throwsException, 39 | ); 40 | }); 41 | 42 | test('Keys.generate', () { 43 | final keys = Keys.generate(); 44 | expect(keys.public.length, 64); 45 | expect(keys.secret.length, 64); 46 | }); 47 | 48 | test('Keys.verify', () { 49 | const hex = 50 | "5ee1c8000ab28edd64d74a7d951ac2dd559814887b1b9e1ac7c5f89e96125c12"; 51 | final keys = Keys(hex); 52 | const message = 53 | "4b697394206581b03ca5222b37449a9cdca1741b122d78defc177444e2536f49"; 54 | const signature = 55 | "797c47bef50eff748b8af0f38edcb390facf664b2367d72eb71c50b5f37bc83c4ae9cc9007e8489f5f63c66a66e101fd1515d0a846385953f5f837efb9afe885"; 56 | 57 | expect( 58 | Schnorr.verify( 59 | publicKey: keys.public, 60 | message: message, 61 | signature: signature, 62 | ), 63 | true); 64 | }); 65 | }); 66 | } 67 | -------------------------------------------------------------------------------- /test/message_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:nostr/nostr.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | void main() { 5 | group('Message', () { 6 | test('EVENT', () { 7 | const String payload = 8 | '["EVENT","0954524188078879",{"id":"da5107e5aa00978e0dd9bb3c09b2303af414fe4c10600b3c61bb938675d191af","kind":1,"pubkey":"da0cc82154bdf4ce8bf417eaa2d2fa99aa65c96c77867d6656fccdbf8e781b18","created_at":1675015235,"content":"#[0] test","tags":[["p","939ddb0c77d18ccd1ebb44c7a32b9cdc29b489e710c54db7cf1383ee86674a24"]],"sig":"f2b507e1039084d07477ccd2876ee0eb9e80f214be80e4e2ab28ad60dc5858297bbd9bf177be230b571c0015deb05c12eb02b659586fd3573cfa84c9292400b5"}]'; 9 | final msg = Message.deserialize(payload); 10 | expect(msg.type, "EVENT"); 11 | expect(msg.message.id, 12 | "da5107e5aa00978e0dd9bb3c09b2303af414fe4c10600b3c61bb938675d191af"); 13 | }); 14 | test('REQ', () { 15 | const String payload = 16 | '["REQ","22055752544101437",{"kinds":[0,1,2,7],"since":1674320733,"limit":450}]'; 17 | final msg = Message.deserialize(payload); 18 | expect(msg.type, "REQ"); 19 | expect(msg.message.filters[0].limit, 450); 20 | }); 21 | test('CLOSE', () { 22 | const String payload = '["CLOSE","anyrandomstring"]'; 23 | final msg = Message.deserialize(payload); 24 | expect(msg.type, "CLOSE"); 25 | expect(msg.message.subscriptionId, "anyrandomstring"); 26 | }); 27 | test('NOTICE', () { 28 | const String payload = 29 | '["NOTICE", "restricted: we can\'t serve DMs to unauthenticated users, does your client implement NIP-42?"]'; 30 | final msg = Message.deserialize(payload); 31 | expect(msg.type, "NOTICE"); 32 | }); 33 | 34 | test('EOSE', () { 35 | const String payload = '["EOSE", "random"]'; 36 | final msg = Message.deserialize(payload); 37 | expect(msg.type, "EOSE"); 38 | expect(msg.message.subscriptionId, "random"); 39 | }); 40 | 41 | test('OK', () { 42 | const String payload = 43 | '["OK", "b1a649ebe8b435ec71d3784793f3bbf4b93e64e17568a741aecd4c7ddeafce30", true, ""]'; 44 | final msg = Message.deserialize(payload); 45 | expect(msg.type, "OK"); 46 | }); 47 | }); 48 | } 49 | -------------------------------------------------------------------------------- /test/nips/nip_002_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:nostr/nostr.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | void main() { 5 | group('nip002', () { 6 | test('Profile constructor', () { 7 | const String key = "91cf9..4e5ca"; 8 | const String relay = "wss://alicerelay.com/"; 9 | const String petname = "alice"; 10 | final profile = Profile(key, relay, petname); 11 | expect(profile.key, key); 12 | expect(profile.relay, relay); 13 | expect(profile.petname, petname); 14 | }); 15 | 16 | test('Nip2.tagsToProfiles', () { 17 | final List> tags = [ 18 | ["p", "91cf9..4e5ca", "wss://alicerelay.com/", "alice"], 19 | ["p", "14aeb..8dad4", "wss://bobrelay.com/nostr", "bob"], 20 | ["p", "612ae..e610f", "ws://carolrelay.com/ws", "carol"] 21 | ]; 22 | final List profiles = Nip2.toProfiles(tags); 23 | expect(profiles[0].key, tags[0][1]); 24 | expect(profiles[1].relay, tags[1][2]); 25 | expect(profiles[2].petname, tags[2][3]); 26 | }); 27 | 28 | test('Nip2.profilesToTags', () { 29 | const String key = "21df6d143fb96c2ec9d63726bf9edc71"; 30 | const String relay = ""; 31 | const String petname = "erin"; 32 | final List profiles = [Profile(key, relay, petname)]; 33 | final List> tags = Nip2.toTags(profiles); 34 | expect(tags[0][0], "p"); 35 | expect(tags[0][1], key); 36 | expect(tags[0][2], relay); 37 | expect(tags[0][3], petname); 38 | }); 39 | 40 | test('Nip2.decode', () { 41 | final event = Event.from( 42 | kind: 3, 43 | tags: [ 44 | ["p", "91cf9..4e5ca", "wss://alicerelay.com/", "alice"], 45 | ["p", "14aeb..8dad4", "wss://bobrelay.com/nostr", "bob"], 46 | ["p", "612ae..e610f", "ws://carolrelay.com/ws", "carol"], 47 | ], 48 | content: "", 49 | privkey: 50 | "5ee1c8000ab28edd64d74a7d951ac2dd559814887b1b9e1ac7c5f89e96125c12", 51 | ); 52 | 53 | final List profiles = Nip2.decode(event); 54 | expect(profiles[0].key, "91cf9..4e5ca"); 55 | expect(profiles[1].relay, "wss://bobrelay.com/nostr"); 56 | expect(profiles[2].petname, "carol"); 57 | }); 58 | 59 | test('Nip2.decode throws Exception', () { 60 | final event = Event.from( 61 | kind: 6, 62 | tags: [], 63 | content: "", 64 | privkey: 65 | "5ee1c8000ab28edd64d74a7d951ac2dd559814887b1b9e1ac7c5f89e96125c12", 66 | ); 67 | expect(() => Nip2.decode(event), throwsException); 68 | }); 69 | }); 70 | } 71 | -------------------------------------------------------------------------------- /test/nips/nip_004_test.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: deprecated_member_use_from_same_package 2 | 3 | import 'package:nostr/nostr.dart'; 4 | import 'package:test/test.dart'; 5 | 6 | String bobPubkey = 7 | "2d38a56c4303bc722370c50c86fc8dd3327f06a8fe59b3ff3d670738d71dd1e1"; 8 | String alicePubkey = 9 | "0f76c800a7ea76b83a3ae87de94c6046b98311bda8885cedd8420885b50de181"; 10 | String bobPrivkey = 11 | "826ef0e93c1278bd89945377fadb6b6b51d9eedf74ecdb64a96f1897bb670be8"; 12 | String alicePrivkey = 13 | "773dc29ff81f7680eeca5d530f528e8c572979b46abc8bfd1586b73a6a98ab4d"; 14 | 15 | void main() { 16 | group('EncryptedDirectMessage', () { 17 | test('EncryptedDirectMessage.quick', () { 18 | // DM from bob to alice 19 | const String plaintext = "vi veri universum vivus vici"; 20 | final List> tags = [ 21 | ['p', alicePubkey] 22 | ]; 23 | 24 | final EncryptedDirectMessage event = 25 | EncryptedDirectMessage.redact(bobPrivkey, alicePubkey, plaintext); 26 | 27 | expect(event.receiver, alicePubkey); 28 | expect(event.getPlaintext(alicePrivkey), plaintext); 29 | expect(event.pubkey, bobPubkey); 30 | expect(event.kind, 4); 31 | expect(event.tags, tags); 32 | expect(event.subscriptionId, null); 33 | }); 34 | 35 | test('EncryptedDirectMessage Receive', () { 36 | const String receivedEvent = 37 | '["EVENT", "181555e0fec2139d27ac80a5a46801415394e61d67be7f626631760dc5997bc0", {"id": "2739cccdc3fa943ad447378e234ef1325a76f023a169b483b6fe8cab47a793e1", "pubkey": "0f76c800a7ea76b83a3ae87de94c6046b98311bda8885cedd8420885b50de181", "created_at": 1680475069, "kind": 4, "tags": [["p", "2d38a56c4303bc722370c50c86fc8dd3327f06a8fe59b3ff3d670738d71dd1e1"]], "content": "hH1HlQWY3dz7IzJlgnEgW1WNtA0KlvGgo/OC4mep/R4I6PMqJuvZ35j4OFHkMvqb?iv=jbkXPH2esn5DIP3BodpsTQ==", "sig": "ba2d80d8be4612e8438447d38373e9b014b28172d49b7b6afeff462bc25bd4edc69c6e2c145dde052de54f47f4e36d74151fad3d08d21e3cad9563edc64adcca"}]'; 38 | 39 | final Message m = Message.deserialize(receivedEvent); 40 | final Event event = m.message; 41 | expect(event.id, 42 | "2739cccdc3fa943ad447378e234ef1325a76f023a169b483b6fe8cab47a793e1"); 43 | expect(event.pubkey, alicePubkey); 44 | expect(event.createdAt, 1680475069); 45 | expect(event.kind, 4); 46 | expect(event.tags, [ 47 | ["p", bobPubkey] 48 | ]); 49 | final String content = 50 | (event as EncryptedDirectMessage).getPlaintext(bobPrivkey); 51 | expect(content, "Secret message from alice to bob!"); 52 | expect(event.sig, 53 | "ba2d80d8be4612e8438447d38373e9b014b28172d49b7b6afeff462bc25bd4edc69c6e2c145dde052de54f47f4e36d74151fad3d08d21e3cad9563edc64adcca"); 54 | expect(event.subscriptionId, 55 | "181555e0fec2139d27ac80a5a46801415394e61d67be7f626631760dc5997bc0"); 56 | }); 57 | }); 58 | } 59 | -------------------------------------------------------------------------------- /test/nips/nip_005_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:nostr/nostr.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | void main() { 5 | group('nip005', () { 6 | test('encode', () { 7 | const hex = 8 | "5ee1c8000ab28edd64d74a7d951ac2dd559814887b1b9e1ac7c5f89e96125c12"; 9 | final user = Keys(hex); 10 | final List relays = [ 11 | 'wss://relay.example.com', 12 | 'wss://relay2.example.com' 13 | ]; 14 | final Event event = 15 | Nip5.encode('name', 'example.com', relays, user.secret); 16 | expect(event.kind, 0); 17 | 18 | expect(() => Nip5.encode('name', 'example', relays, user.secret), 19 | throwsException); 20 | expect(() => Nip5.encode('name!', 'example.com', relays, user.secret), 21 | throwsException); 22 | }); 23 | 24 | test('decode', () async { 25 | final event = Event.from( 26 | kind: 0, 27 | tags: [], 28 | content: 29 | '{"name":"name","nip05":"name@example.com","relays":["wss://relay.example.com","wss://relay2.example.com"]}', 30 | privkey: 31 | "5ee1c8000ab28edd64d74a7d951ac2dd559814887b1b9e1ac7c5f89e96125c12", 32 | ); 33 | final DNS? dns = await Nip5.decode(event); 34 | expect(dns!.name, 'name'); 35 | expect(dns.domain, 'example.com'); 36 | expect(dns.pubkey, event.pubkey); 37 | expect( 38 | dns.relays, ['wss://relay.example.com', 'wss://relay2.example.com']); 39 | }); 40 | }); 41 | } 42 | -------------------------------------------------------------------------------- /test/nips/nip_009_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:nostr/nostr.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | const privkey = 5 | '5ee1c8000ab28edd64d74a7d951ac2dd559814887b1b9e1ac7c5f89e96125c12'; 6 | const pubkey = 7 | '0ba0206887bd61579bf65ec09d7806bea32c64be1cf2c978cf031a811cd238db'; 8 | 9 | /// Unit Tests for Nip9 10 | void main() { 11 | test('toTags should convert event IDs to tags', () { 12 | final List eventIds = ["event1", "event2"]; 13 | final List> expectedTags = [ 14 | ["e", "event1"], 15 | ["e", "event2"] 16 | ]; 17 | expect(Nip9.toTags(eventIds), equals(expectedTags)); 18 | }); 19 | 20 | test('tagsToList should extract event IDs from tags', () { 21 | final List> tags = [ 22 | ["e", "event1"], 23 | ["e", "event2"] 24 | ]; 25 | final List expectedEventIds = ["event1", "event2"]; 26 | expect(Nip9.tagsToList(tags), equals(expectedEventIds)); 27 | }); 28 | 29 | test('encode should create a valid Event object', () { 30 | final List eventIds = ["event1", "event2"]; 31 | const String content = "Reason"; 32 | 33 | final Event event = Nip9.encode(eventIds, content, pubkey, privkey); 34 | expect(event.kind, equals(5)); 35 | expect( 36 | event.tags, 37 | equals([ 38 | ["e", "event1"], 39 | ["e", "event2"] 40 | ])); 41 | expect(event.content, equals(content)); 42 | expect(event.pubkey, equals(pubkey)); 43 | }); 44 | 45 | test('decode should convert a valid Event to a DeleteEvent', () { 46 | final Event event = Event.from( 47 | kind: 5, 48 | tags: [ 49 | ["e", "event1"], 50 | ["e", "event2"] 51 | ], 52 | content: "Reason", 53 | pubkey: pubkey, 54 | privkey: privkey, 55 | ); 56 | final Nip9DeletionRequest deleteEvent = Nip9.decode(event); 57 | expect(deleteEvent.pubkey, equals(pubkey)); 58 | expect(deleteEvent.deleteEvents, equals(["event1", "event2"])); 59 | expect(deleteEvent.reason, equals("Reason")); 60 | }); 61 | } 62 | -------------------------------------------------------------------------------- /test/nips/nip_010_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:nostr/nostr.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | void main() { 5 | group('nip010', () { 6 | test('fromTags', () { 7 | final List> tags = [ 8 | ["e", '91cf9..4e5ca', 'wss://alicerelay.com', "root"], 9 | ["e", '14aeb..8dad4', 'wss://bobrelay.com/nostr', "reply"], 10 | ["p", '612ae..e610f', 'ws://carolrelay.com/ws'], 11 | ]; 12 | final Thread thread = Nip10.fromTags(tags); 13 | expect(thread.root.eventId, '91cf9..4e5ca'); 14 | expect(thread.root.relayURL, 'wss://alicerelay.com'); 15 | expect(thread.root.marker, 'root'); 16 | 17 | expect(thread.etags[0].eventId, '14aeb..8dad4'); 18 | expect(thread.etags[0].relayURL, 'wss://bobrelay.com/nostr'); 19 | expect(thread.etags[0].marker, 'reply'); 20 | 21 | expect(thread.ptags[0].pubkey, '612ae..e610f'); 22 | expect(thread.ptags[0].relayURL, 'ws://carolrelay.com/ws'); 23 | }); 24 | 25 | test('toTags', () { 26 | final ETag root = Nip10.rootTag('91cf9..4e5ca', 'wss://alicerelay.com'); 27 | final ETag eTag = 28 | ETag("14aeb..8dad4", "wss://bobrelay.com/nostr", "reply"); 29 | final PTag pTag = PTag("612ae..e610f", "ws://carolrelay.com/ws"); 30 | final Thread thread = Thread(root, [eTag], [pTag]); 31 | 32 | expect(thread.root.eventId, '91cf9..4e5ca'); 33 | expect(thread.etags[0].eventId, "14aeb..8dad4"); 34 | expect(thread.ptags[0].pubkey, "612ae..e610f"); 35 | }); 36 | }); 37 | } 38 | -------------------------------------------------------------------------------- /test/nips/nip_013_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:nostr/nostr.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | /// Unit Tests for Nip13 5 | void main() { 6 | test('countLeadingZeroes calculates correct difficulty for IDs', () { 7 | expect( 8 | Nip13.countLeadingZeroes( 9 | "000000000e9d97a1ab09fc381030b346cdd7a142ad57e6df0b46dc9bef6c7e2d", 10 | ), 11 | equals(36), 12 | ); 13 | expect(Nip13.countLeadingZeroes("002f"), equals(10)); 14 | expect(Nip13.countLeadingZeroes("f0"), equals(0)); 15 | }); 16 | } 17 | -------------------------------------------------------------------------------- /test/nips/nip_017_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:nostr/nostr.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | void main() async { 5 | const authorNsec = 6 | 'nsec1w8udu59ydjvedgs3yv5qccshcj8k05fh3l60k9x57asjrqdpa00qkmr89m'; 7 | final author = Keys(authorNsec); 8 | 9 | const recipientNsec = 10 | 'nsec12ywtkplvyq5t6twdqwwygavp5lm4fhuang89c943nf2z92eez43szvn4dt'; 11 | final recipient = Keys(recipientNsec); 12 | 13 | const message = 'Hola, que tal?'; 14 | 15 | final dm = await Nip17.encode( 16 | authorPrivkey: author.secret, 17 | receiverPubkey: recipient.public, 18 | message: message, 19 | ); 20 | 21 | group('NIP-17 Direct Message', () { 22 | test('encode a direct message', () async { 23 | expect(dm.kind, 1059); 24 | expect(dm.tags, [ 25 | [ 26 | "p", 27 | "918e2da906df4ccd12c8ac672d8335add131a4cf9d27ce42b3bb3625755f0788" 28 | ] 29 | ]); 30 | }); 31 | 32 | test('decode a direct message', () async { 33 | final x = await Nip17.decode( 34 | giftWrap: dm, 35 | receiverPrivkey: recipient.secret, 36 | ); 37 | 38 | expect(x.kind, 14); 39 | expect(x.pubkey, author.public); 40 | expect(x.sig, isEmpty); 41 | expect(x.tags, [ 42 | [ 43 | "p", 44 | "918e2da906df4ccd12c8ac672d8335add131a4cf9d27ce42b3bb3625755f0788" 45 | ] 46 | ]); 47 | }); 48 | }); 49 | } 50 | -------------------------------------------------------------------------------- /test/nips/nip_019_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:nostr/nostr.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | void main() { 5 | group('nip019', () { 6 | test('encode', () { 7 | final nsec = Nip19.encode( 8 | prefix: Nip19Prefix.nsec, 9 | data: 10 | "5ee1c8000ab28edd64d74a7d951ac2dd559814887b1b9e1ac7c5f89e96125c12"); 11 | final npub = Nip19.encode( 12 | prefix: Nip19Prefix.npub, 13 | data: 14 | "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d"); 15 | final note = Nip19.encode( 16 | prefix: Nip19Prefix.note, 17 | data: 18 | "2739cccdc3fa943ad447378e234ef1325a76f023a169b483b6fe8cab47a793e1"); 19 | 20 | expect(nsec, 21 | 'nsec1tmsusqq2k28d6exhff7e2xkzm42es9yg0vdeuxk8chufa9sjtsfq8z3spp'); 22 | expect(npub, 23 | 'npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6'); 24 | expect(note, 25 | 'note1yuuuenwrl22r44z8x78zxnh3xfd8dupr595mfqakl6x2k3a8j0ssj0w27g'); 26 | }); 27 | 28 | test('decode', () { 29 | final privkey = Nip19.decode( 30 | payload: 31 | "nsec1tmsusqq2k28d6exhff7e2xkzm42es9yg0vdeuxk8chufa9sjtsfq8z3spp"); 32 | final pubkey = Nip19.decode( 33 | payload: 34 | "npub180cvv07tjdrrgpa0j7j7tmnyl2yr6yr7l8j4s3evf6u64th6gkwsyjh6w6"); 35 | final note = Nip19.decode( 36 | payload: 37 | "note1yuuuenwrl22r44z8x78zxnh3xfd8dupr595mfqakl6x2k3a8j0ssj0w27g"); 38 | 39 | expect(privkey.prefix, Nip19Prefix.nsec); 40 | expect(privkey.data, 41 | '5ee1c8000ab28edd64d74a7d951ac2dd559814887b1b9e1ac7c5f89e96125c12'); 42 | expect(pubkey.prefix, Nip19Prefix.npub); 43 | expect(pubkey.data, 44 | '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'); 45 | expect(note.prefix, Nip19Prefix.note); 46 | expect(note.data, 47 | '2739cccdc3fa943ad447378e234ef1325a76f023a169b483b6fe8cab47a793e1'); 48 | }); 49 | }); 50 | 51 | test('decode nprofile', () { 52 | final x = Nip19.decodeShareableIdentifiers( 53 | payload: 54 | "nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhhytnc9e3k7mgpz4mhxue69uhkg6nzv9ejuumpv34kytnrdaksjlyr9p"); 55 | 56 | expect(x.prefix, Nip19Prefix.nprofile); 57 | expect(x.special, 58 | '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d'); 59 | expect(x.relays[0], 'wss://r.x.com'); 60 | expect(x.relays[1], 'wss://djbas.sadkb.com'); 61 | }); 62 | 63 | test('encode nprofile', () { 64 | final y = Nip19.encodeShareableIdentifiers( 65 | prefix: Nip19Prefix.nprofile, 66 | special: 67 | '3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d', 68 | relays: ['wss://r.x.com', 'wss://djbas.sadkb.com'], 69 | ); 70 | 71 | expect(y, 72 | 'nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhhytnc9e3k7mgpz4mhxue69uhkg6nzv9ejuumpv34kytnrdaksjlyr9p'); 73 | }); 74 | } 75 | -------------------------------------------------------------------------------- /test/nips/nip_020_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:nostr/nostr.dart'; 4 | import 'package:test/test.dart'; 5 | 6 | void main() { 7 | group('nip20', () { 8 | test('Constructor', () { 9 | const eventId = 10 | "b1a649ebe8b435ec71d3784793f3bbf4b93e64e17568a741aecd4c7ddeafce30"; 11 | const status = true; 12 | const message = ""; 13 | final nip20 = Nip20(eventId, status, message); 14 | expect(nip20.eventId, eventId); 15 | expect(nip20.status, status); 16 | expect(nip20.message, message); 17 | }); 18 | 19 | test('serialize', () { 20 | const eventId = 21 | "b1a649ebe8b435ec71d3784793f3bbf4b93e64e17568a741aecd4c7ddeafce30"; 22 | const status = true; 23 | const message = ""; 24 | const stringNip20 = '["OK","$eventId",$status,"$message"]'; 25 | final nip20 = Nip20(eventId, status, message); 26 | expect(nip20.serialize(), stringNip20); 27 | }); 28 | 29 | test('deserialize', () { 30 | final serializedNip20 = [ 31 | "OK", 32 | "b1a649ebe8b435ec71d3784793f3bbf4b93e64e17568a741aecd4c7ddeafce30", 33 | true, 34 | "" 35 | ]; 36 | final nip20 = Nip20.deserialize(json.encode(serializedNip20)); 37 | expect(nip20.eventId, serializedNip20[1]); 38 | expect(nip20.status, serializedNip20[2]); 39 | expect(nip20.message, serializedNip20[3]); 40 | }); 41 | }); 42 | } 43 | -------------------------------------------------------------------------------- /test/nips/nip_021_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:nostr/nostr.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | void main() { 5 | group('Nip21 URI Tests', () { 6 | test('Parse valid npub URI', () { 7 | expect( 8 | Nip21.decode( 9 | 'nostr:npub1sn0wdenkukak0d9dfczzeacvhkrgz92ak56egt7vdgzn8pv2wfqqhrjdv9'), 10 | equals( 11 | 'npub1sn0wdenkukak0d9dfczzeacvhkrgz92ak56egt7vdgzn8pv2wfqqhrjdv9')); 12 | }); 13 | 14 | test('Parse valid nprofile URI', () { 15 | expect( 16 | Nip21.decode( 17 | 'nostr:nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhhytnc9e3k7mgpz4mhxue69uhkg6nzv9ejuumpv34kytnrdaksjlyr9p'), 18 | equals( 19 | 'nprofile1qqsrhuxx8l9ex335q7he0f09aej04zpazpl0ne2cgukyawd24mayt8gpp4mhxue69uhhytnc9e3k7mgpz4mhxue69uhkg6nzv9ejuumpv34kytnrdaksjlyr9p')); 20 | }); 21 | 22 | test('Generate npub URI', () { 23 | expect( 24 | Nip21.encode( 25 | 'npub1sn0wdenkukak0d9dfczzeacvhkrgz92ak56egt7vdgzn8pv2wfqqhrjdv9'), 26 | equals( 27 | 'nostr:npub1sn0wdenkukak0d9dfczzeacvhkrgz92ak56egt7vdgzn8pv2wfqqhrjdv9')); 28 | }); 29 | 30 | test('Invalid Nostr URI parsing', () { 31 | expect(() => Nip21.decode('noprefix'), throwsA(isA())); 32 | }); 33 | }); 34 | } 35 | -------------------------------------------------------------------------------- /test/nips/nip_023_test.dart: -------------------------------------------------------------------------------- 1 | // test/nip23_test.dart 2 | 3 | import 'package:nostr/nostr.dart'; 4 | import 'package:test/test.dart'; 5 | 6 | void main() { 7 | group('Nip23.decode', () { 8 | test('should decode a valid event map into a Nip23Article', () { 9 | // Arrange 10 | final Map map = { 11 | 'id': '', 12 | 'kind': Nip23.kindArticle, 13 | 'created_at': 1675642635, 14 | 'content': 'This is a decoded article content.', 15 | 'tags': [ 16 | ['d', 'decoded-article'], 17 | ['title', 'Decoded Article'], 18 | ['image', 'https://example.com/decoded_image.png'], 19 | ['summary', 'A summary of the decoded article.'], 20 | ['published_at', '1296962229'], 21 | ['t', 'dart'], 22 | ['t', 'testing'], 23 | ['custom_tag', 'custom_value'], 24 | ], 25 | 'pubkey': 'pubkey456', 26 | 'sig': '' 27 | }; 28 | 29 | final event = Event.fromMap(map, verify: false); 30 | final article = Nip23.decode(event); 31 | 32 | // Assert 33 | expect(article.kind, equals(Nip23.kindArticle)); 34 | expect(article.content, equals('This is a decoded article content.')); 35 | expect(article.pubkey, equals('pubkey456')); 36 | expect(article.createdAt, equals(1675642635)); 37 | expect(article.articleId, equals('decoded-article')); 38 | expect(article.title, equals('Decoded Article')); 39 | expect(article.image, equals('https://example.com/decoded_image.png')); 40 | expect(article.summary, equals('A summary of the decoded article.')); 41 | expect(article.publishedAt, equals(1296962229)); 42 | expect(article.topics, equals(['dart', 'testing'])); 43 | expect( 44 | article.additionalTags, 45 | equals([ 46 | ['custom_tag', 'custom_value'], 47 | ])); 48 | }); 49 | }); 50 | } 51 | -------------------------------------------------------------------------------- /test/nips/nip_028_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:nostr/nostr.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | void main() { 5 | group('nip028', () { 6 | test('createChannel', () { 7 | const String privkey = 8 | "826ef0e93c1278bd89945377fadb6b6b51d9eedf74ecdb64a96f1897bb670be8"; 9 | final Event event = Nip28.createChannel( 10 | 'name', 11 | 'about', 12 | 'http://image.jpg', 13 | { 14 | 'badges': 15 | '0f76c800a7ea76b83a3ae87de94c6046b98311bda8885cedd8420885b50de181' 16 | }, 17 | privkey); 18 | final Channel channel = Nip28.getChannelCreation(event); 19 | expect(channel.picture, 'http://image.jpg'); 20 | expect(channel.additional['badges'], 21 | '0f76c800a7ea76b83a3ae87de94c6046b98311bda8885cedd8420885b50de181'); 22 | }); 23 | 24 | test('setMetadata', () { 25 | const String privkey = 26 | "826ef0e93c1278bd89945377fadb6b6b51d9eedf74ecdb64a96f1897bb670be8"; 27 | final Event event = Nip28.setChannelMetaData( 28 | 'name', 29 | 'about', 30 | 'http://image.jpg', 31 | { 32 | 'badges': 33 | '0f76c800a7ea76b83a3ae87de94c6046b98311bda8885cedd8420885b50de181' 34 | }, 35 | 'b83a3326b63470df6a86dca9456184e09ea1a237b2b41b36e0af740badf329e9', 36 | 'wss://example.com', 37 | privkey); 38 | final Channel channel = Nip28.getChannelMetadata(event); 39 | expect(channel.channelId, 40 | "b83a3326b63470df6a86dca9456184e09ea1a237b2b41b36e0af740badf329e9"); 41 | expect(channel.picture, 'http://image.jpg'); 42 | expect(channel.additional['badges'], 43 | '0f76c800a7ea76b83a3ae87de94c6046b98311bda8885cedd8420885b50de181'); 44 | }); 45 | 46 | test('sendChannelMessage', () { 47 | const String privkey = 48 | "826ef0e93c1278bd89945377fadb6b6b51d9eedf74ecdb64a96f1897bb670be8"; 49 | final Event event = Nip28.sendChannelMessage( 50 | "b83a3326b63470df6a86dca9456184e09ea1a237b2b41b36e0af740badf329e9", 51 | 'content', 52 | privkey); 53 | final ChannelMessage channelMessage = Nip28.getChannelMessage(event); 54 | 55 | expect(channelMessage.channelId, 56 | "b83a3326b63470df6a86dca9456184e09ea1a237b2b41b36e0af740badf329e9"); 57 | expect(channelMessage.content, 'content'); 58 | 59 | /// reply & p 60 | final ETag eTag = ETag( 61 | '0f76c800a7ea76b83a3ae87de94c6046b98311bda8885cedd8420885b50de181', 62 | "wss://example.com", 63 | 'reply'); 64 | final PTag pTag = PTag( 65 | '2d38a56c4303bc722370c50c86fc8dd3327f06a8fe59b3ff3d670738d71dd1e1', 66 | "wss://example.com"); 67 | final Event event2 = Nip28.sendChannelMessage( 68 | "b83a3326b63470df6a86dca9456184e09ea1a237b2b41b36e0af740badf329e9", 69 | 'content', 70 | privkey, 71 | etags: [eTag], 72 | ptags: [pTag]); 73 | final ChannelMessage channelMessage2 = Nip28.getChannelMessage(event2); 74 | 75 | expect(channelMessage2.channelId, 76 | "b83a3326b63470df6a86dca9456184e09ea1a237b2b41b36e0af740badf329e9"); 77 | expect(channelMessage2.content, 'content'); 78 | expect(channelMessage2.thread.etags[0].eventId, 79 | '0f76c800a7ea76b83a3ae87de94c6046b98311bda8885cedd8420885b50de181'); 80 | expect(channelMessage2.thread.ptags[0].pubkey, 81 | '2d38a56c4303bc722370c50c86fc8dd3327f06a8fe59b3ff3d670738d71dd1e1'); 82 | }); 83 | 84 | test('hideChannelMessage', () { 85 | const String privkey = 86 | "826ef0e93c1278bd89945377fadb6b6b51d9eedf74ecdb64a96f1897bb670be8"; 87 | final Event event = Nip28.hideChannelMessage( 88 | "b83a3326b63470df6a86dca9456184e09ea1a237b2b41b36e0af740badf329e9", 89 | "reason", 90 | privkey); 91 | final ChannelMessageHidden channelMessageHidden = 92 | Nip28.getMessageHidden(event); 93 | 94 | expect(channelMessageHidden.operator, 95 | '2d38a56c4303bc722370c50c86fc8dd3327f06a8fe59b3ff3d670738d71dd1e1'); 96 | expect(channelMessageHidden.messageId, 97 | 'b83a3326b63470df6a86dca9456184e09ea1a237b2b41b36e0af740badf329e9'); 98 | }); 99 | 100 | test('muteUser', () { 101 | const String privkey = 102 | "826ef0e93c1278bd89945377fadb6b6b51d9eedf74ecdb64a96f1897bb670be8"; 103 | final Event event = Nip28.muteUser( 104 | "b83a3326b63470df6a86dca9456184e09ea1a237b2b41b36e0af740badf329e9", 105 | "reason", 106 | privkey); 107 | final ChannelUserMuted channelUserMuted = Nip28.getUserMuted(event); 108 | 109 | expect(channelUserMuted.operator, 110 | '2d38a56c4303bc722370c50c86fc8dd3327f06a8fe59b3ff3d670738d71dd1e1'); 111 | expect(channelUserMuted.userPubkey, 112 | 'b83a3326b63470df6a86dca9456184e09ea1a237b2b41b36e0af740badf329e9'); 113 | }); 114 | }); 115 | } 116 | -------------------------------------------------------------------------------- /test/nips/nip_051_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:nostr/nostr.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | void main() { 5 | group('nip051', () { 6 | test('createCategorizedPeople', () { 7 | final Keys user = Keys.generate(); 8 | final People publicFriend = People( 9 | "2d38a56c4303bc722370c50c86fc8dd3327f06a8fe59b3ff3d670738d71dd1e1", 10 | 'wss://example.com', 11 | 'alias', 12 | "0f76c800a7ea76b83a3ae87de94c6046b98311bda8885cedd8420885b50de181"); 13 | final People privateFriend = People( 14 | "0f76c800a7ea76b83a3ae87de94c6046b98311bda8885cedd8420885b50de181", 15 | 'wss://example2.com', 16 | 'bob', 17 | "2d38a56c4303bc722370c50c86fc8dd3327f06a8fe59b3ff3d670738d71dd1e1"); 18 | final Event event = Nip51.createCategorizedPeople( 19 | "friends", [publicFriend], [privateFriend], user.secret, user.public); 20 | 21 | final Lists lists = Nip51.getLists(event, user.secret); 22 | expect(lists.people[0].pubkey, 23 | '2d38a56c4303bc722370c50c86fc8dd3327f06a8fe59b3ff3d670738d71dd1e1'); 24 | expect(lists.people[0].petName, 'alias'); 25 | expect(lists.people[1].pubkey, 26 | '0f76c800a7ea76b83a3ae87de94c6046b98311bda8885cedd8420885b50de181'); 27 | expect(lists.people[1].petName, 'bob'); 28 | }); 29 | 30 | test('createCategorizedBookmarks', () { 31 | final Keys user = Keys.generate(); 32 | const String bookmark = 33 | '2d38a56c4303bc722370c50c86fc8dd3327f06a8fe59b3ff3d670738d71dd1e1'; 34 | const String encryptedBookmark = 35 | '0f76c800a7ea76b83a3ae87de94c6046b98311bda8885cedd8420885b50de181'; 36 | final Event event = Nip51.createCategorizedBookmarks("bookmarks", 37 | [bookmark], [encryptedBookmark], user.secret, user.public); 38 | 39 | final Lists lists = Nip51.getLists(event, user.secret); 40 | expect(lists.bookmarks[0], 41 | '2d38a56c4303bc722370c50c86fc8dd3327f06a8fe59b3ff3d670738d71dd1e1'); 42 | expect(lists.bookmarks[1], 43 | '0f76c800a7ea76b83a3ae87de94c6046b98311bda8885cedd8420885b50de181'); 44 | }); 45 | 46 | test('createMutePeople', () { 47 | final Keys user = Keys.generate(); 48 | final People publicFriend = People( 49 | "2d38a56c4303bc722370c50c86fc8dd3327f06a8fe59b3ff3d670738d71dd1e1", 50 | 'wss://example.com', 51 | 'alias', 52 | "0f76c800a7ea76b83a3ae87de94c6046b98311bda8885cedd8420885b50de181"); 53 | final People privateFriend = People( 54 | "0f76c800a7ea76b83a3ae87de94c6046b98311bda8885cedd8420885b50de181", 55 | 'wss://example2.com', 56 | 'bob', 57 | "2d38a56c4303bc722370c50c86fc8dd3327f06a8fe59b3ff3d670738d71dd1e1"); 58 | final Event event = Nip51.createMutePeople( 59 | [publicFriend], [privateFriend], user.secret, user.public); 60 | final Lists lists = Nip51.getLists(event, user.secret); 61 | expect(lists.people[0].pubkey, 62 | '2d38a56c4303bc722370c50c86fc8dd3327f06a8fe59b3ff3d670738d71dd1e1'); 63 | expect(lists.people[0].petName, 'alias'); 64 | expect(lists.people[1].pubkey, 65 | '0f76c800a7ea76b83a3ae87de94c6046b98311bda8885cedd8420885b50de181'); 66 | expect(lists.people[1].petName, 'bob'); 67 | }); 68 | 69 | test('createPinEvent', () { 70 | final Keys user = Keys.generate(); 71 | const String bookmark = 72 | '2d38a56c4303bc722370c50c86fc8dd3327f06a8fe59b3ff3d670738d71dd1e1'; 73 | const String encryptedBookmark = 74 | '0f76c800a7ea76b83a3ae87de94c6046b98311bda8885cedd8420885b50de181'; 75 | final Event event = Nip51.createPinEvent( 76 | [bookmark], [encryptedBookmark], user.secret, user.public); 77 | 78 | final Lists lists = Nip51.getLists(event, user.secret); 79 | expect(lists.bookmarks[0], 80 | '2d38a56c4303bc722370c50c86fc8dd3327f06a8fe59b3ff3d670738d71dd1e1'); 81 | expect(lists.bookmarks[1], 82 | '0f76c800a7ea76b83a3ae87de94c6046b98311bda8885cedd8420885b50de181'); 83 | }); 84 | }); 85 | } 86 | -------------------------------------------------------------------------------- /test/nips/nip_059_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:nostr/nostr.dart'; 2 | import 'package:test/test.dart'; 3 | 4 | void main() { 5 | group('NIP-59 Gift Wrap Tests', () { 6 | // Example from the spec 7 | const authorPrivkey = 8 | '0beebd062ec8735f4243466049d7747ef5d6594ee838de147f8aab842b15e273'; 9 | const recipientPrivkey = 10 | 'e108399bd8424357a710b606ae0c13166d853d327e47a6e5e038197346bdbf45'; 11 | const ephemeralPrivkey = 12 | '4f02eac59266002db5801adc5270700ca69d5b8f761d8732fab2fbf233c90cbd'; 13 | 14 | const rumorContent = 'Are you going to the party tonight?'; 15 | const rumorCreatedAt = 1691518405; 16 | const rumorPubkey = 17 | '611df01bfcf85c26ae65453b772d8f1dfd25c264621c0277e1fc1518686faef9'; 18 | 19 | test('Wrap & Unwrap yields the same rumor content (NIP-59 example)', 20 | () async { 21 | // 1) Construct an UNSIGNED rumor (kind=1, no .id, no .sig) 22 | // If your "Event" constructor auto-signs, you may need to forcibly remove .id/.sig. 23 | final rumor = Event.partial( 24 | tags: [], 25 | content: rumorContent, 26 | createdAt: rumorCreatedAt, 27 | pubkey: rumorPubkey, 28 | ); 29 | 30 | // 2) Wrap the rumor: 31 | // - Seal => kind=13 => signed by author 32 | // - GiftWrap => kind=1059 => signed by ephemeral 33 | // We'll explicitly specify ephemeralPrivkey from the spec. 34 | final giftWrap = await Nip59.wrap( 35 | rumor: rumor, 36 | authorPrivkey: authorPrivkey, 37 | recipientPubkey: Keys(recipientPrivkey).public, 38 | ephemeralPrivkey: ephemeralPrivkey, 39 | createdAt: 1703021488, 40 | ); 41 | 42 | expect(giftWrap.kind, 1059); 43 | expect(giftWrap.createdAt, 1703021488); 44 | expect(giftWrap.tags, [ 45 | [ 46 | "p", 47 | "166bf3765ebd1fc55decfe395beff2ea3b2a4e0a8946e7eb578512b555737c99" 48 | ] 49 | ]); 50 | expect(giftWrap.pubkey, 51 | '18b1a75918f1f2c90c23da616bce317d36e348bcf5f7ba55e75949319210c87c'); 52 | expect(giftWrap.content, isNotEmpty); 53 | expect(giftWrap.sig, isNotNull, 54 | reason: 'giftWrap must be signed by ephemeral key'); 55 | 56 | // 3) The recipient unwraps 57 | final unwrappedRumor = await Nip59.unwrap( 58 | giftWrap: giftWrap, 59 | recipientPrivkey: recipientPrivkey, 60 | ); 61 | 62 | // The unwrapped rumor should be kind=1, no signature, same content 63 | expect(unwrappedRumor.kind, 1); 64 | expect(unwrappedRumor.content, rumorContent); 65 | expect(unwrappedRumor.sig, isEmpty, reason: 'Rumor must remain unsigned'); 66 | expect(unwrappedRumor.id, isEmpty, 67 | reason: 'Rumor is never broadcast as itself'); 68 | }); 69 | }); 70 | } 71 | -------------------------------------------------------------------------------- /test/request_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:nostr/src/filter.dart'; 4 | import 'package:nostr/src/request.dart'; 5 | import 'package:test/test.dart'; 6 | 7 | void main() { 8 | group('Request', () { 9 | test('Constructor.toJson', () { 10 | final Filter myFilter = Filter( 11 | ids: [ 12 | "047663d895d56aefa3f528935c7ce7dc8939eb721a0ec76ef2e558a8257955d2" 13 | ], 14 | authors: [ 15 | "0ba0206887bd61579bf65ec09d7806bea32c64be1cf2c978cf031a811cd238db" 16 | ], 17 | kinds: [0, 1, 2, 7], 18 | e: [], 19 | a: [], 20 | p: [], 21 | since: 1672477960, 22 | until: 1674063680, 23 | limit: 450, 24 | search: "term", 25 | ); 26 | 27 | final req = 28 | Request(subscriptionId: "733209259899167", filters: [myFilter]); 29 | 30 | expect(req.subscriptionId, "733209259899167"); 31 | expect(req.filters[0].ids, myFilter.ids); 32 | expect(req.filters[0].authors, myFilter.authors); 33 | expect(req.filters[0].kinds, myFilter.kinds); 34 | expect(req.filters[0].e, myFilter.e); 35 | expect(req.filters[0].a, myFilter.a); 36 | expect(req.filters[0].p, myFilter.p); 37 | expect(req.filters[0].kinds, myFilter.kinds); 38 | expect(req.filters[0].since, myFilter.since); 39 | expect(req.filters[0].until, myFilter.until); 40 | expect(req.filters[0].limit, myFilter.limit); 41 | expect(req.filters[0].search, myFilter.search); 42 | }); 43 | 44 | test('Request.serialize', () { 45 | const String serialized = 46 | '["REQ","733209259899167",{"ids":["047663d895d56aefa3f528935c7ce7dc8939eb721a0ec76ef2e558a8257955d2"],"authors":["0ba0206887bd61579bf65ec09d7806bea32c64be1cf2c978cf031a811cd238db"],"kinds":[0,1,2,7],"#e":[],"#a":[],"#p":[],"since":1672477960,"until":1674063680,"limit":450,"search":"term"},{"kinds":[0,1,2,7],"since":1673980547,"limit":450}]'; 47 | final payload = json.encode([ 48 | "REQ", 49 | "733209259899167", 50 | { 51 | "ids": [ 52 | "047663d895d56aefa3f528935c7ce7dc8939eb721a0ec76ef2e558a8257955d2" 53 | ], 54 | "authors": [ 55 | "0ba0206887bd61579bf65ec09d7806bea32c64be1cf2c978cf031a811cd238db" 56 | ], 57 | "kinds": [0, 1, 2, 7], 58 | "#e": [], 59 | "#a": [], 60 | "#p": [], 61 | "since": 1672477960, 62 | "until": 1674063680, 63 | "limit": 450, 64 | "search": "term", 65 | }, 66 | { 67 | "kinds": [0, 1, 2, 7], 68 | "since": 1673980547, 69 | "limit": 450 70 | } 71 | ]); 72 | final Request req = Request.deserialize(payload); 73 | expect(req.serialize(), serialized); 74 | }); 75 | 76 | test('Request.serialize with empty filters', () { 77 | final req = Request(subscriptionId: 'id'); 78 | expect( 79 | req.serialize(), 80 | '["REQ","id",{}]', 81 | ); 82 | }); 83 | 84 | test('Request.deserialize with empty filters', () { 85 | const payload = '["REQ","id",{}]'; 86 | final req = Request.deserialize(payload); 87 | expect(req.subscriptionId, 'id'); 88 | expect(req.filters.length, 1); 89 | }); 90 | 91 | test('Request.deserialize', () { 92 | final payload = json.encode([ 93 | "REQ", 94 | "733209259899167", 95 | { 96 | "ids": [ 97 | "047663d895d56aefa3f528935c7ce7dc8939eb721a0ec76ef2e558a8257955d2" 98 | ], 99 | "authors": [ 100 | "0ba0206887bd61579bf65ec09d7806bea32c64be1cf2c978cf031a811cd238db" 101 | ], 102 | "kinds": [0, 1, 2, 7], 103 | "#e": [], 104 | "#a": [], 105 | "#p": [], 106 | "since": 1672477960, 107 | "until": 1674063680, 108 | "limit": 450, 109 | "search": "term", 110 | }, 111 | { 112 | "kinds": [0, 1, 2, 7], 113 | "since": 1673980547, 114 | "limit": 450 115 | } 116 | ]); 117 | final Request req = Request.deserialize(payload); 118 | expect(req.subscriptionId, "733209259899167"); 119 | expect(req.filters[0].ids, 120 | ["047663d895d56aefa3f528935c7ce7dc8939eb721a0ec76ef2e558a8257955d2"]); 121 | expect(req.filters[0].authors, 122 | ["0ba0206887bd61579bf65ec09d7806bea32c64be1cf2c978cf031a811cd238db"]); 123 | expect(req.filters[0].e, []); 124 | expect(req.filters[0].a, []); 125 | expect(req.filters[0].p, []); 126 | expect(req.filters[0].kinds, [0, 1, 2, 7]); 127 | expect(req.filters[0].since, 1672477960); 128 | expect(req.filters[0].until, 1674063680); 129 | expect(req.filters[0].limit, 450); 130 | expect(req.filters[0].search, "term"); 131 | expect(req.filters[1].kinds, [0, 1, 2, 7]); 132 | expect(req.filters[1].since, 1673980547); 133 | expect(req.filters[1].limit, 450); 134 | }); 135 | }); 136 | } 137 | --------------------------------------------------------------------------------