nostr_ruby 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/nostr_ruby.rb CHANGED
@@ -1,284 +1,23 @@
1
- require_relative 'custom_addr'
1
+ require_relative 'version'
2
2
  require_relative 'crypto_tools'
3
+
3
4
  require 'ecdsa'
4
5
  require 'schnorr'
5
6
  require 'json'
6
7
  require 'base64'
7
8
  require 'bech32'
8
9
  require 'unicode/emoji'
9
- require 'websocket-client-simple'
10
-
11
- # * Ruby library to interact with the Nostr protocol
12
-
13
- class Nostr
14
- include CryptoTools
15
-
16
- attr_reader :private_key, :public_key, :pow_difficulty_target, :nip26_delegation_tag
17
-
18
- def initialize(key)
19
- hex_private_key = if key[:private_key]&.include?('nsec')
20
- Nostr.to_hex(key[:private_key])
21
- else
22
- key[:private_key]
23
- end
24
-
25
- hex_public_key = if key[:public_key]&.include?('npub')
26
- Nostr.to_hex(key[:public_key])
27
- else
28
- key[:public_key]
29
- end
30
-
31
- if hex_private_key
32
- @private_key = hex_private_key
33
- group = ECDSA::Group::Secp256k1
34
- @public_key = group.generator.multiply_by_scalar(private_key.to_i(16)).x.to_s(16).rjust(64, '0')
35
- elsif hex_public_key
36
- @public_key = hex_public_key
37
- else
38
- raise 'Missing private or public key'
39
- end
40
- end
41
-
42
- def keys
43
- keys = { public_key: @public_key }
44
- keys[:private_key] = @private_key if @private_key
45
- keys
46
- end
47
-
48
- def bech32_keys
49
- bech32_keys = { public_key: Nostr.to_bech32(@public_key, 'npub') }
50
- bech32_keys[:private_key] = Nostr.to_bech32(@private_key, 'nsec') if @private_key
51
- bech32_keys
52
- end
53
-
54
- def self.to_hex(bech32_key)
55
- public_addr = CustomAddr.new(bech32_key)
56
- public_addr.to_scriptpubkey
57
- end
58
-
59
- def self.to_bech32(hex_key, hrp)
60
- custom_addr = CustomAddr.new
61
- custom_addr.scriptpubkey = hex_key
62
- custom_addr.hrp = hrp
63
- custom_addr.addr
64
- end
65
-
66
- def sign_event(event)
67
- raise 'Invalid pubkey' unless event[:pubkey].is_a?(String) && event[:pubkey].size == 64
68
- raise 'Invalid created_at' unless event[:created_at].is_a?(Integer)
69
- raise 'Invalid kind' unless (0..29_999).include?(event[:kind])
70
- raise 'Invalid tags' unless event[:tags].is_a?(Array)
71
- raise 'Invalid content' unless event[:content].is_a?(String)
72
-
73
- serialized_event = [
74
- 0,
75
- event[:pubkey],
76
- event[:created_at],
77
- event[:kind],
78
- event[:tags],
79
- event[:content]
80
- ]
81
-
82
- serialized_event_sha256 = nil
83
- if @pow_difficulty_target
84
- nonce = 1
85
- loop do
86
- nonce_tag = ['nonce', nonce.to_s, @pow_difficulty_target.to_s]
87
- nonced_serialized_event = serialized_event.clone
88
- nonced_serialized_event[4] = nonced_serialized_event[4] + [nonce_tag]
89
- serialized_event_sha256 = Digest::SHA256.hexdigest(JSON.dump(nonced_serialized_event))
90
- if match_pow_difficulty?(serialized_event_sha256)
91
- event[:tags] << nonce_tag
92
- break
93
- end
94
- nonce += 1
95
- end
96
- else
97
- serialized_event_sha256 = Digest::SHA256.hexdigest(JSON.dump(serialized_event))
98
- end
99
-
100
- private_key = Array(@private_key).pack('H*')
101
- message = Array(serialized_event_sha256).pack('H*')
102
- event_signature = Schnorr.sign(message, private_key).encode.unpack('H*')[0]
103
-
104
- event['id'] = serialized_event_sha256
105
- event['sig'] = event_signature
106
- event
107
- end
108
-
109
- def build_event(payload)
110
- if @nip26_delegation_tag
111
- payload[:tags] = [] unless payload[:tags]
112
- payload[:tags] << @nip26_delegation_tag
113
- end
114
- event = sign_event(payload)
115
- ['EVENT', event]
116
- end
117
-
118
- def build_metadata_event(name, about, picture, nip05)
119
- data = {}
120
- data[:name] = name if name
121
- data[:about] = about if about
122
- data[:picture] = picture if picture
123
- data[:nip05] = nip05 if nip05
124
- event = {
125
- "pubkey": @public_key,
126
- "created_at": Time.now.utc.to_i,
127
- "kind": 0,
128
- "tags": [],
129
- "content": data.to_json
130
- }
131
-
132
- build_event(event)
133
- end
134
-
135
- def build_note_event(text, channel_key = nil)
136
- event = {
137
- "pubkey": @public_key,
138
- "created_at": Time.now.utc.to_i,
139
- "kind": channel_key ? 42 : 1,
140
- "tags": channel_key ? [['e', channel_key]] : [],
141
- "content": text
142
- }
143
-
144
- build_event(event)
145
- end
146
-
147
- def build_recommended_relay_event(relay)
148
- raise 'Invalid relay' unless relay.start_with?('wss://') || relay.start_with?('ws://')
149
-
150
- event = {
151
- "pubkey": @public_key,
152
- "created_at": Time.now.utc.to_i,
153
- "kind": 2,
154
- "tags": [],
155
- "content": relay
156
- }
157
-
158
- build_event(event)
159
- end
160
-
161
- def build_contact_list_event(contacts)
162
- event = {
163
- "pubkey": @public_key,
164
- "created_at": Time.now.utc.to_i,
165
- "kind": 3,
166
- "tags": contacts.map { |c| ['p'] + c },
167
- "content": ''
168
- }
169
-
170
- build_event(event)
171
- end
172
-
173
- def build_dm_event(text, recipient_public_key)
174
- encrypted_text = CryptoTools.aes_256_cbc_encrypt(@private_key, recipient_public_key, text)
175
-
176
- event = {
177
- "pubkey": @public_key,
178
- "created_at": Time.now.utc.to_i,
179
- "kind": 4,
180
- "tags": [['p', recipient_public_key]],
181
- "content": encrypted_text
182
- }
183
-
184
- build_event(event)
185
- end
186
-
187
- def build_deletion_event(events, reason = '')
188
- event = {
189
- "pubkey": @public_key,
190
- "created_at": Time.now.utc.to_i,
191
- "kind": 5,
192
- "tags": events.map{ |e| ['e', e] },
193
- "content": reason
194
- }
195
-
196
- build_event(event)
197
- end
198
-
199
- def build_reaction_event(reaction, event, author)
200
- raise 'Invalid reaction' unless ['+', '-'].include?(reaction) || reaction.match?(Unicode::Emoji::REGEX)
201
- raise 'Invalid author' unless event.is_a?(String) && event.size == 64
202
- raise 'Invalid event' unless author.is_a?(String) && author.size == 64
203
-
204
- event = {
205
- "pubkey": @public_key,
206
- "created_at": Time.now.utc.to_i,
207
- "kind": 7,
208
- "tags": [['e', event], ['p', author]],
209
- "content": reaction
210
- }
211
-
212
- build_event(event)
213
- end
214
-
215
- def decrypt_dm(event)
216
- data = event[1]
217
- sender_public_key = data[:pubkey] != @public_key ? data[:pubkey] : data[:tags][0][1]
218
- encrypted = data[:content].split('?iv=')[0]
219
- iv = data[:content].split('?iv=')[1]
220
- CryptoTools.aes_256_cbc_decrypt(@private_key, sender_public_key, encrypted, iv)
221
- end
222
-
223
- def get_delegation_tag(delegatee_pubkey, conditions)
224
- delegation_message_sha256 = Digest::SHA256.hexdigest("nostr:delegation:#{delegatee_pubkey}:#{conditions}")
225
- signature = Schnorr.sign(Array(delegation_message_sha256).pack('H*'), Array(@private_key).pack('H*')).encode.unpack('H*')[0]
226
- [
227
- "delegation",
228
- @public_key,
229
- conditions,
230
- signature
231
- ]
232
- end
233
-
234
- def set_delegation(tag)
235
- @nip26_delegation_tag = tag
236
- end
237
-
238
- def reset_delegation
239
- @nip26_delegation_tag = nil
240
- end
241
-
242
- def self.verify_delegation_signature(delegatee_pubkey, tag)
243
- delegation_message_sha256 = Digest::SHA256.hexdigest("nostr:delegation:#{delegatee_pubkey}:#{tag[2]}")
244
- Schnorr.valid_sig?(Array(delegation_message_sha256).pack('H*'), Array(tag[1]).pack('H*'), Array(tag[3]).pack('H*'))
245
- end
246
-
247
- def build_req_event(filters)
248
- ['REQ', SecureRandom.random_number.to_s, filters]
249
- end
250
-
251
- def build_close_event(subscription_id)
252
- ['CLOSE', subscription_id]
253
- end
254
-
255
- def build_notice_event(message)
256
- ['NOTICE', message]
257
- end
258
-
259
- def match_pow_difficulty?(event_id)
260
- @pow_difficulty_target.nil? || @pow_difficulty_target == [event_id].pack("H*").unpack("B*")[0].index('1')
261
- end
262
10
 
263
- def set_pow_difficulty_target(n)
264
- @pow_difficulty_target = n
265
- end
11
+ require_relative 'bech32'
12
+ require_relative 'context'
13
+ require_relative 'kind'
14
+ require_relative 'key'
15
+ require_relative 'event'
16
+ require_relative 'filter'
17
+ require_relative 'signer'
18
+ require_relative 'client'
19
+ require_relative 'message_handler'
266
20
 
267
- def test_post_event(event, relay)
268
- response = nil
269
- ws = WebSocket::Client::Simple.connect relay
270
- ws.on :message do |msg|
271
- puts msg
272
- response = JSON.parse(msg.data)
273
- ws.close
274
- end
275
- ws.on :open do
276
- ws.send event.to_json
277
- end
278
- while response.nil? do
279
- sleep 0.1
280
- end
281
- response[0] == 'OK'
282
- end
21
+ module Nostr
283
22
 
284
- end
23
+ end
data/lib/signer.rb ADDED
@@ -0,0 +1,89 @@
1
+ module Nostr
2
+ class Signer
3
+
4
+ attr_reader :private_key
5
+ attr_reader :public_key
6
+
7
+ def initialize(private_key:)
8
+ @private_key = private_key
9
+ unless @public_key
10
+ @public_key = Nostr::Key::get_public_key(@private_key)
11
+ end
12
+ end
13
+
14
+ def nsec
15
+ Nostr::Bech32.encode_nsec(@private_key)
16
+ end
17
+
18
+ def npub
19
+ Nostr::Bech32.encode_npub(@public_key)
20
+ end
21
+
22
+ def sign(event)
23
+
24
+ raise ArgumentError, "Event is not signable" unless event.signable?
25
+
26
+ event.pubkey = @public_key if event.pubkey.nil? || event.pubkey.empty?
27
+
28
+ raise ArgumentError, "Pubkey doesn't match the private key" unless event.pubkey == @public_key
29
+
30
+ if event.kind == Nostr::Kind::DIRECT_MESSAGE
31
+ dm_recipient = event.tags.select{|t| t[0] == "p"}.first[1]
32
+ event.content = CryptoTools.aes_256_cbc_encrypt(@private_key, dm_recipient, event.content)
33
+ end
34
+
35
+ if event.delegation
36
+ event.tags << event.delegation
37
+ end
38
+
39
+ event_sha256_digest = nil
40
+ if event.pow
41
+ nonce = 1
42
+ loop do
43
+ nonce_tag = ['nonce', nonce.to_s, event.pow.to_s]
44
+ nonced_serialized_event = event.serialize.clone
45
+ nonced_serialized_event[4] = nonced_serialized_event[4] + [nonce_tag]
46
+ event_sha256_digest = Digest::SHA256.hexdigest(JSON.dump(nonced_serialized_event))
47
+ if Nostr::Event.match_pow_difficulty?(event_sha256_digest, event.pow)
48
+ event.tags << nonce_tag
49
+ break
50
+ end
51
+ nonce += 1
52
+ end
53
+ else
54
+ event_sha256_digest = Digest::SHA256.hexdigest(JSON.dump(event.serialize))
55
+ end
56
+
57
+ event.id = event_sha256_digest
58
+ binary_private_key = Array(@private_key).pack('H*')
59
+ binary_message = Array(event.id).pack('H*')
60
+ event.sig = Schnorr.sign(binary_message, binary_private_key).encode.unpack('H*')[0]
61
+ event
62
+ end
63
+
64
+ def decrypt(event)
65
+ case event.kind
66
+ when Nostr::Kind::DIRECT_MESSAGE
67
+ data = event.content.split('?iv=')[0]
68
+ iv = event.content.split('?iv=')[1]
69
+ dm_recipient = event.tags.select{|t| t[0] == "p"}.first[1]
70
+ event.content = CryptoTools.aes_256_cbc_decrypt(@private_key, dm_recipient, data, iv)
71
+ event
72
+ else
73
+ raise "Unable to decrypt a kind #{event.kind} event"
74
+ end
75
+ end
76
+
77
+ def generate_delegation_tag(to:, conditions:)
78
+ delegation_message_sha256 = Digest::SHA256.hexdigest("nostr:delegation:#{to}:#{conditions}")
79
+ signature = Schnorr.sign(Array(delegation_message_sha256).pack('H*'), Array(@private_key).pack('H*')).encode.unpack('H*')[0]
80
+ [
81
+ "delegation",
82
+ @public_key,
83
+ conditions,
84
+ signature
85
+ ]
86
+ end
87
+
88
+ end
89
+ end
data/lib/version.rb ADDED
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Nostr
4
+ VERSION = '0.3.1'
5
+ end
data/nostr_ruby.gemspec CHANGED
@@ -1,9 +1,9 @@
1
1
  $:.unshift File.expand_path('../lib', __FILE__)
2
- require 'nostr_ruby/version'
2
+ require 'version'
3
3
 
4
4
  Gem::Specification.new do |s|
5
5
  s.name = 'nostr_ruby'
6
- s.version = NostrRuby::VERSION
6
+ s.version = Nostr::VERSION
7
7
  s.summary = 'A Ruby library to interact with the Nostr protocol'
8
8
  s.description = 'NostrRuby is a Ruby library to interact with the Nostr protocol. At this stage the focus is the creation of public events and private encrypted messages.'
9
9
  s.authors = ['Daniele Tonon']
@@ -13,10 +13,10 @@ Gem::Specification.new do |s|
13
13
  s.platform = Gem::Platform::RUBY
14
14
  s.require_paths = ['lib']
15
15
 
16
- s.add_dependency 'base64', '~> 0.1.1'
17
- s.add_dependency 'bech32', '~> 1.3.0'
18
- s.add_dependency 'bip-schnorr', '~> 0.4.0'
19
- s.add_dependency 'json', '~> 2.6.2'
20
- s.add_dependency 'unicode-emoji', '~> 3.3.1'
21
- s.add_dependency 'websocket-client-simple', '~> 0.6.0'
16
+ s.add_dependency 'base64', '>= 0.3.0', '< 0.4.0'
17
+ s.add_dependency 'bech32', '>= 1.4.0', '< 2.0'
18
+ s.add_dependency 'bip-schnorr', '>= 0.4.0', '< 1.0'
19
+ s.add_dependency 'faye-websocket', '>= 0.11', '< 1.0'
20
+ s.add_dependency 'json', '>= 2.6.2', '< 3.0'
21
+ s.add_dependency 'unicode-emoji', '>= 3.3.1', '< 5.0'
22
22
  end
metadata CHANGED
@@ -1,99 +1,135 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: nostr_ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Daniele Tonon
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-04-14 00:00:00.000000000 Z
11
+ date: 2025-11-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64
15
15
  requirement: !ruby/object:Gem::Requirement
16
16
  requirements:
17
- - - "~>"
17
+ - - ">="
18
18
  - !ruby/object:Gem::Version
19
- version: 0.1.1
19
+ version: 0.3.0
20
+ - - "<"
21
+ - !ruby/object:Gem::Version
22
+ version: 0.4.0
20
23
  type: :runtime
21
24
  prerelease: false
22
25
  version_requirements: !ruby/object:Gem::Requirement
23
26
  requirements:
24
- - - "~>"
27
+ - - ">="
25
28
  - !ruby/object:Gem::Version
26
- version: 0.1.1
29
+ version: 0.3.0
30
+ - - "<"
31
+ - !ruby/object:Gem::Version
32
+ version: 0.4.0
27
33
  - !ruby/object:Gem::Dependency
28
34
  name: bech32
29
35
  requirement: !ruby/object:Gem::Requirement
30
36
  requirements:
31
- - - "~>"
37
+ - - ">="
32
38
  - !ruby/object:Gem::Version
33
- version: 1.3.0
39
+ version: 1.4.0
40
+ - - "<"
41
+ - !ruby/object:Gem::Version
42
+ version: '2.0'
34
43
  type: :runtime
35
44
  prerelease: false
36
45
  version_requirements: !ruby/object:Gem::Requirement
37
46
  requirements:
38
- - - "~>"
47
+ - - ">="
48
+ - !ruby/object:Gem::Version
49
+ version: 1.4.0
50
+ - - "<"
39
51
  - !ruby/object:Gem::Version
40
- version: 1.3.0
52
+ version: '2.0'
41
53
  - !ruby/object:Gem::Dependency
42
54
  name: bip-schnorr
43
55
  requirement: !ruby/object:Gem::Requirement
44
56
  requirements:
45
- - - "~>"
57
+ - - ">="
46
58
  - !ruby/object:Gem::Version
47
59
  version: 0.4.0
60
+ - - "<"
61
+ - !ruby/object:Gem::Version
62
+ version: '1.0'
48
63
  type: :runtime
49
64
  prerelease: false
50
65
  version_requirements: !ruby/object:Gem::Requirement
51
66
  requirements:
52
- - - "~>"
67
+ - - ">="
53
68
  - !ruby/object:Gem::Version
54
69
  version: 0.4.0
70
+ - - "<"
71
+ - !ruby/object:Gem::Version
72
+ version: '1.0'
55
73
  - !ruby/object:Gem::Dependency
56
- name: json
74
+ name: faye-websocket
57
75
  requirement: !ruby/object:Gem::Requirement
58
76
  requirements:
59
- - - "~>"
77
+ - - ">="
60
78
  - !ruby/object:Gem::Version
61
- version: 2.6.2
79
+ version: '0.11'
80
+ - - "<"
81
+ - !ruby/object:Gem::Version
82
+ version: '1.0'
62
83
  type: :runtime
63
84
  prerelease: false
64
85
  version_requirements: !ruby/object:Gem::Requirement
65
86
  requirements:
66
- - - "~>"
87
+ - - ">="
67
88
  - !ruby/object:Gem::Version
68
- version: 2.6.2
89
+ version: '0.11'
90
+ - - "<"
91
+ - !ruby/object:Gem::Version
92
+ version: '1.0'
69
93
  - !ruby/object:Gem::Dependency
70
- name: unicode-emoji
94
+ name: json
71
95
  requirement: !ruby/object:Gem::Requirement
72
96
  requirements:
73
- - - "~>"
97
+ - - ">="
74
98
  - !ruby/object:Gem::Version
75
- version: 3.3.1
99
+ version: 2.6.2
100
+ - - "<"
101
+ - !ruby/object:Gem::Version
102
+ version: '3.0'
76
103
  type: :runtime
77
104
  prerelease: false
78
105
  version_requirements: !ruby/object:Gem::Requirement
79
106
  requirements:
80
- - - "~>"
107
+ - - ">="
81
108
  - !ruby/object:Gem::Version
82
- version: 3.3.1
109
+ version: 2.6.2
110
+ - - "<"
111
+ - !ruby/object:Gem::Version
112
+ version: '3.0'
83
113
  - !ruby/object:Gem::Dependency
84
- name: websocket-client-simple
114
+ name: unicode-emoji
85
115
  requirement: !ruby/object:Gem::Requirement
86
116
  requirements:
87
- - - "~>"
117
+ - - ">="
118
+ - !ruby/object:Gem::Version
119
+ version: 3.3.1
120
+ - - "<"
88
121
  - !ruby/object:Gem::Version
89
- version: 0.6.0
122
+ version: '5.0'
90
123
  type: :runtime
91
124
  prerelease: false
92
125
  version_requirements: !ruby/object:Gem::Requirement
93
126
  requirements:
94
- - - "~>"
127
+ - - ">="
128
+ - !ruby/object:Gem::Version
129
+ version: 3.3.1
130
+ - - "<"
95
131
  - !ruby/object:Gem::Version
96
- version: 0.6.0
132
+ version: '5.0'
97
133
  description: NostrRuby is a Ruby library to interact with the Nostr protocol. At this
98
134
  stage the focus is the creation of public events and private encrypted messages.
99
135
  email:
@@ -105,10 +141,19 @@ files:
105
141
  - Gemfile.lock
106
142
  - LICENSE.md
107
143
  - README.md
144
+ - lib/bech32.rb
145
+ - lib/client.rb
146
+ - lib/context.rb
108
147
  - lib/crypto_tools.rb
109
- - lib/custom_addr.rb
148
+ - lib/event.rb
149
+ - lib/event_wizard.rb
150
+ - lib/filter.rb
151
+ - lib/key.rb
152
+ - lib/kind.rb
153
+ - lib/message_handler.rb
110
154
  - lib/nostr_ruby.rb
111
- - lib/nostr_ruby/version.rb
155
+ - lib/signer.rb
156
+ - lib/version.rb
112
157
  - nostr_ruby.gemspec
113
158
  homepage: https://github.com/dtonon/nostr-ruby
114
159
  licenses:
data/lib/custom_addr.rb DELETED
@@ -1,59 +0,0 @@
1
- class CustomAddr
2
-
3
- attr_accessor :hrp # human-readable part
4
- attr_accessor :prog # witness program
5
-
6
- def initialize(addr = nil)
7
- @hrp, @prog = parse_addr(addr) if addr
8
- end
9
-
10
- def to_scriptpubkey
11
- prog.map{|p|[p].pack("C")}.join.unpack('H*').first
12
- end
13
-
14
- def scriptpubkey=(script)
15
- values = [script].pack('H*').unpack("C*")
16
- @prog = values
17
- end
18
-
19
- def addr
20
- spec = Bech32::Encoding::BECH32
21
- Bech32.encode(hrp, convert_bits(prog, 8, 5), spec)
22
- end
23
-
24
- private
25
-
26
- def parse_addr(addr)
27
- hrp, data, spec = Bech32.decode(addr)
28
- raise 'Invalid address.' if hrp.nil? || data[0].nil?
29
- # raise 'Invalid witness version' if ver > 16
30
- prog = convert_bits(data, 5, 8, false)
31
- # raise 'Invalid witness program' if prog.nil? || prog.length < 2 || prog.length > 40
32
- # raise 'Invalid witness program with version 0' if ver == 0 && (prog.length != 20 && prog.length != 32)
33
- [hrp, prog]
34
- end
35
-
36
- def convert_bits(data, from, to, padding=true)
37
- acc = 0
38
- bits = 0
39
- ret = []
40
- maxv = (1 << to) - 1
41
- max_acc = (1 << (from + to - 1)) - 1
42
- data.each do |v|
43
- return nil if v < 0 || (v >> from) != 0
44
- acc = ((acc << from) | v) & max_acc
45
- bits += from
46
- while bits >= to
47
- bits -= to
48
- ret << ((acc >> bits) & maxv)
49
- end
50
- end
51
- if padding
52
- ret << ((acc << (to - bits)) & maxv) unless bits == 0
53
- elsif bits >= from || ((acc << (to - bits)) & maxv) != 0
54
- return nil
55
- end
56
- ret
57
- end
58
-
59
- end
@@ -1,3 +0,0 @@
1
- module NostrRuby
2
- VERSION = '0.2.0'
3
- end