├── .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 | [](https://www.gnu.org/licenses/lgpl-3.0)
2 | [](https://github.com/ethicnology/dart-nostr/actions/workflows/dart-test.yml)
3 | [](https://pub.dartlang.org/packages/nostr)
4 | [](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 |
--------------------------------------------------------------------------------