bluzelle 0.1.0

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.
Files changed (3) hide show
  1. checksums.yaml +7 -0
  2. data/lib/bluzelle.rb +436 -0
  3. metadata +85 -0
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: be7bb808d24a2b87f8d1f128d49b7076dd51d4357e6b5dc81c1302d2e1c1d9b3
4
+ data.tar.gz: 2576a055d888bcf7726584541274a1dbd3bef680019ddfd0c97fede963cb82f5
5
+ SHA512:
6
+ metadata.gz: 3d124f196fff63444c10197f0b9b6108f70eab8aaf2fdb6e80a85e1815efcc1abd7b9f058b0cd362001b8c583637da9641a90a8bed2ef294580b514494040f11
7
+ data.tar.gz: e20c60cb8c21bae2fa4b35903f7f6fef8fc1ed1b40470241bafb6756733279c3900bba7d654b9dabfaa26e206f3b6e2c8fd53f3087783cabebdc1985c72320b8
@@ -0,0 +1,436 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/setup'
4
+ require 'json'
5
+ require 'digest'
6
+ require 'logger'
7
+ require 'net/http'
8
+ require 'securerandom'
9
+ require 'ecdsa'
10
+ require 'bip_mnemonic'
11
+ require 'money-tree'
12
+ require 'secp256k1'
13
+ require_relative 'bech32'
14
+
15
+ DEFAULT_ENDPOINT = 'http://localhost:1317'
16
+ DEFAULT_CHAIN_ID = 'bluzelle'
17
+ HD_PATH = "m/44'/118'/0'/0/0"
18
+ ADDRESS_PREFIX = 'bluzelle'
19
+ TX_COMMAND = '/txs'
20
+ TOKEN_NAME = 'ubnt'
21
+ PUB_KEY_TYPE = 'tendermint/PubKeySecp256k1'
22
+ BROADCAST_MAX_RETRIES = 10
23
+ BROADCAST_RETRY_INTERVAL_SECONDS = 1
24
+ MSG_KEYS_ORDER = %w[
25
+ Key
26
+ KeyValues
27
+ Lease
28
+ N
29
+ NewKey
30
+ Owner
31
+ UUID
32
+ Value
33
+ ].freeze
34
+
35
+ module Bluzelle
36
+ class OptionsError < StandardError
37
+ end
38
+
39
+ class APIError < StandardError
40
+ end
41
+
42
+ def self.new_client options
43
+ raise OptionsError, 'address is required' unless options.fetch('address', nil)
44
+ raise OptionsError, 'mnemonic is required' unless options.fetch('mnemonic', nil)
45
+
46
+ gas_info = options.fetch('gas_info', {})
47
+ unless gas_info.class.equal?(Hash)
48
+ raise OptionsError, 'gas_info should be a dict of {gas_price, max_fee, max_gas}'
49
+ end
50
+
51
+ gas_info_keys = %w[gas_price max_fee max_gas]
52
+ gas_info_keys.each do |k|
53
+ v = gas_info.fetch(k, 0)
54
+ unless v.class.equal?(Integer)
55
+ raise OptionsError, 'gas_info[%s] should be an int' % k
56
+ end
57
+
58
+ gas_info[k] = v
59
+ end
60
+
61
+ options['debug'] = false unless options.fetch('debug', false)
62
+ options['chain_id'] = DEFAULT_CHAIN_ID unless options.fetch('chain_id', nil)
63
+ options['endpoint'] = DEFAULT_ENDPOINT unless options.fetch('endpoint', nil)
64
+
65
+ c = Client.new options
66
+ c.setup_logging
67
+ c.set_private_key
68
+ c.verify_address
69
+ c.set_account
70
+ c
71
+ end
72
+
73
+ private
74
+
75
+ class Client
76
+ def initialize(options)
77
+ @options = options
78
+ end
79
+
80
+ def setup_logging
81
+ @logger = Logger.new(STDOUT)
82
+ @logger.level = if @options['debug'] then Logger::DEBUG else Logger::FATAL end
83
+ end
84
+
85
+ def set_private_key
86
+ seed = BipMnemonic.to_seed(mnemonic: @options['mnemonic'])
87
+ master = MoneyTree::Master.new(seed_hex: seed)
88
+ @wallet = master.node_for_path(HD_PATH)
89
+ end
90
+
91
+ def verify_address
92
+ b = Digest::RMD160.digest(Digest::SHA256.digest(@wallet.public_key.compressed.to_bytes))
93
+ address = Bech32.encode(ADDRESS_PREFIX, Bech32.convert_bits(b, from_bits: 8, to_bits: 5, pad: true))
94
+ if address != @options['address']
95
+ raise OptionsError, 'bad credentials(verify your address and mnemonic)'
96
+ end
97
+ end
98
+
99
+ def set_account
100
+ @account = read_account
101
+ end
102
+
103
+ #
104
+
105
+ def read_account
106
+ url = "/auth/accounts/#{@options['address']}"
107
+ api_query(url)['result']['value']
108
+ end
109
+
110
+ def version()
111
+ url = "/node_info"
112
+ api_query(url)["application_version"]["version"]
113
+ end
114
+
115
+ # mutate
116
+
117
+ def create(key, value, lease: 0)
118
+ send_transaction('post', '/crud/create', { 'Key' => key, 'Lease' => lease.to_s, 'Value' => value })
119
+ end
120
+
121
+ def update(key, value, lease: nil)
122
+ payload = {"Key" => key}
123
+ payload["Lease"] = lease.to_s if lease.is_a? Integer
124
+ payload["Value"] = value
125
+ send_transaction("post", "/crud/update", payload)
126
+ end
127
+
128
+ def delete(key)
129
+ send_transaction("delete", "/crud/delete", {"Key" => key})
130
+ end
131
+
132
+ def rename(key, new_key)
133
+ send_transaction("post", "/crud/rename", {"Key" => key, "NewKey" => new_key})
134
+ end
135
+
136
+ def delete_all()
137
+ send_transaction("post", "/crud/deleteall", {})
138
+ end
139
+
140
+ def multi_update(payload)
141
+ list = []
142
+ payload.each do |key, value|
143
+ list.append({"key" => key, "value" => value})
144
+ end
145
+ send_transaction("post", "/crud/multiupdate", {"KeyValues" => list})
146
+ end
147
+
148
+ def renew_lease(key, lease)
149
+ send_transaction("post", "/crud/renewlease", {"Key" => key, "Lease" => lease.to_s})
150
+ end
151
+
152
+ def renew_all_leases(lease)
153
+ send_transaction("post", "/crud/renewleaseall", {"Lease" => lease.to_s})
154
+ end
155
+
156
+ # query
157
+
158
+ def read(key)
159
+ url = "/crud/read/#{@options["uuid"]}/#{key}"
160
+ api_query(url)["result"]["value"]
161
+ end
162
+
163
+ def proven_read(key)
164
+ url = "/crud/pread/#{@options["uuid"]}/#{key}"
165
+ api_query(url)["result"]["value"]
166
+ end
167
+
168
+ def has(key)
169
+ url = "/crud/has/#{@options["uuid"]}/#{key}"
170
+ api_query(url)["result"]["has"]
171
+ end
172
+
173
+ def count()
174
+ url = "/crud/count/#{@options["uuid"]}"
175
+ api_query(url)["result"]["count"].to_i
176
+ end
177
+
178
+ def keys()
179
+ url = "/crud/keys/#{@options["uuid"]}"
180
+ api_query(url)["result"]["keys"]
181
+ end
182
+
183
+ def key_values()
184
+ url = "/crud/keyvalues/#{@options["uuid"]}"
185
+ api_query(url)["result"]["keyvalues"]
186
+ end
187
+
188
+ def get_lease(key)
189
+ url = "/crud/getlease/#{@options["uuid"]}/#{key}"
190
+ api_query(url)["result"]["lease"].to_i
191
+ end
192
+
193
+ def get_n_shortest_leases(n)
194
+ url = "/crud/getnshortestlease/#{@options["uuid"]}/#{n}"
195
+ api_query(url)["result"]["keyleases"]
196
+ end
197
+
198
+ #
199
+
200
+ def tx_read(key)
201
+ res = send_transaction("post", "/crud/read", {"Key" => key})
202
+ res["value"]
203
+ end
204
+
205
+ def tx_has(key)
206
+ res = send_transaction("post", "/crud/has", {"Key" => key})
207
+ res["has"]
208
+ end
209
+
210
+ def tx_count()
211
+ res = send_transaction("post", "/crud/count", {})
212
+ res["count"].to_i
213
+ end
214
+
215
+ def tx_keys()
216
+ res = send_transaction("post", "/crud/keys", {})
217
+ res["keys"]
218
+ end
219
+
220
+ def tx_key_values()
221
+ res = send_transaction("post", "/crud/keyvalues", {})
222
+ res["keyvalues"]
223
+ end
224
+
225
+ def tx_get_lease(key)
226
+ res = send_transaction("post", "/crud/getlease", {"Key" => key})
227
+ res["lease"].to_i
228
+ end
229
+
230
+ def tx_get_n_shortest_leases(n)
231
+ res = send_transaction("post", "/crud/getnshortestlease", {"N" => n.to_s})
232
+ res["keyleases"]
233
+ end
234
+
235
+ #
236
+
237
+ def api_query(endpoint)
238
+ url = @options['endpoint'] + endpoint
239
+ @logger.debug("querying url(#{url})...")
240
+ response = Net::HTTP.get_response URI(url)
241
+ data = JSON.parse(response.body)
242
+ error = get_response_error(data)
243
+ raise error if error
244
+ @logger.debug("response (#{data})...")
245
+ data
246
+ end
247
+
248
+ def api_mutate(method, endpoint, payload)
249
+ url = @options['endpoint'] + endpoint
250
+ @logger.debug("mutating url(#{url}), method(#{method})")
251
+ uri = URI(url)
252
+ http = Net::HTTP.new(uri.host, uri.port)
253
+ if method == "delete"
254
+ request = Net::HTTP::Delete.new(uri.request_uri)
255
+ else
256
+ request = Net::HTTP::Post.new(uri.request_uri)
257
+ end
258
+ request['Accept'] = 'application/json'
259
+ request.content_type = 'application/json'
260
+ data = Bluzelle::json_dumps payload
261
+ @logger.debug("data(#{data})")
262
+ request.body = data
263
+ response = http.request(request)
264
+ @logger.debug("response (#{response.body})...")
265
+ data = JSON.parse(response.body)
266
+ error = get_response_error(data)
267
+ raise error if error
268
+ data
269
+ end
270
+
271
+ def send_transaction(method, endpoint, payload)
272
+ @broadcast_retries = 0
273
+ txn = validate_transaction(method, endpoint, payload)
274
+ broadcast_transaction(txn)
275
+ end
276
+
277
+ def validate_transaction(method, endpoint, payload)
278
+ address = @options['address']
279
+ payload = payload.merge({
280
+ 'BaseReq' => {
281
+ 'chain_id' => @options['chain_id'],
282
+ 'from' => address
283
+ },
284
+ 'Owner' => address,
285
+ 'UUID' => @options['uuid']
286
+ })
287
+ api_mutate(method, endpoint, payload)['value']
288
+ end
289
+
290
+ def broadcast_transaction(data)
291
+ # fee
292
+ fee = data['fee']
293
+ fee_gas = fee['gas'].to_i
294
+ gas_info = @options['gas_info']
295
+ if gas_info['max_gas'] != 0 && fee_gas > gas_info['max_gas']
296
+ fee['gas'] = gas_info['max_gas'].to_s
297
+ end
298
+ if gas_info['max_fee'] != 0
299
+ fee['amount'] = [{ 'denom' => TOKEN_NAME, 'amount' => gas_info['max_fee'].to_s }]
300
+ elsif gasInfo['gas_price'] != 0
301
+ fee['amount'] = [{ 'denom' => TOKEN_NAME, 'amount' => (fee_gas * gas_info['gas_price']).to_s }]
302
+ end
303
+
304
+ # sort
305
+ txn = build_txn(
306
+ fee: fee,
307
+ msg: data['msg'][0]
308
+ )
309
+
310
+ # signatures
311
+ txn['signatures'] = [{
312
+ 'account_number' => @account['account_number'].to_s,
313
+ 'pub_key' => {
314
+ 'type' => PUB_KEY_TYPE,
315
+ 'value' => Bluzelle::base64_encode(@wallet.public_key.compressed.to_bytes)
316
+ },
317
+ 'sequence' => @account['sequence'].to_s,
318
+ 'signature' => sign_transaction(txn)
319
+ }]
320
+
321
+ payload = { 'mode' => 'block', 'tx' => txn }
322
+ response = api_mutate('post', TX_COMMAND, payload)
323
+
324
+ # https://github.com/bluzelle/blzjs/blob/45fe51f6364439fa88421987b833102cc9bcd7c0/src/swarmClient/cosmos.js#L240-L246
325
+ # note - as of right now (3/6/20) the responses returned by the Cosmos REST interface now look like this:
326
+ # success case: {"height":"0","txhash":"3F596D7E83D514A103792C930D9B4ED8DCF03B4C8FD93873AB22F0A707D88A9F","raw_log":"[]"}
327
+ # failure case: {"height":"0","txhash":"DEE236DEF1F3D0A92CB7EE8E442D1CE457EE8DB8E665BAC1358E6E107D5316AA","code":4,
328
+ # "raw_log":"unauthorized: signature verification failed; verify correct account sequence and chain-id"}
329
+ #
330
+ # this is far from ideal, doesn't match their docs, and is probably going to change (again) in the future.
331
+ unless response.fetch('code', nil)
332
+ @account['sequence'] += 1
333
+ if response.fetch('data', nil)
334
+ return JSON.parse Bluzelle::hex_to_ascii response['data']
335
+ end
336
+ return
337
+ end
338
+
339
+ raw_log = response["raw_log"]
340
+ if raw_log.include?("signature verification failed")
341
+ @broadcast_retries += 1
342
+ @logger.warn("transaction failed ... retrying(#{@broadcast_retries}) ...")
343
+ if @broadcast_retries >= BROADCAST_MAX_RETRIES
344
+ raise APIError, "transaction failed after max retry attempts"
345
+ end
346
+
347
+ sleep BROADCAST_RETRY_INTERVAL_SECONDS
348
+ set_account()
349
+ broadcast_transaction(txn)
350
+ return
351
+ end
352
+
353
+ raise APIError, raw_log
354
+ end
355
+
356
+ def sign_transaction(txn)
357
+ payload = {
358
+ 'account_number' => @account['account_number'].to_s,
359
+ 'chain_id' => @options['chain_id'],
360
+ 'fee' => txn['fee'],
361
+ 'memo' => txn['memo'],
362
+ 'msgs' => txn['msg'],
363
+ 'sequence' => @account['sequence'].to_s
364
+ }
365
+ payload = Bluzelle::json_dumps(payload)
366
+
367
+ pk = Secp256k1::PrivateKey.new(privkey: @wallet.private_key.to_bytes, raw: true)
368
+ rs = pk.ecdsa_sign payload
369
+ r = rs.slice(0, 32).read_string.reverse
370
+ s = rs.slice(32, 32).read_string.reverse
371
+ sig = "#{r}#{s}"
372
+ Bluzelle::base64_encode sig
373
+ end
374
+
375
+ def build_txn(fee:, msg:)
376
+ # TODO: find a better way to sort
377
+ fee_amount = fee['amount'][0]
378
+ txn = {
379
+ 'fee' => {
380
+ "amount" => [
381
+ {
382
+ "amount" => fee_amount['amount'],
383
+ "denom" => fee_amount['denom']
384
+ }
385
+ ],
386
+ "gas" => fee['gas']
387
+ },
388
+ 'memo' => Bluzelle::make_random_string(32)
389
+ }
390
+ msg_value = msg['value']
391
+ sorted_msg_value = {}
392
+ MSG_KEYS_ORDER.each do |key|
393
+ val = msg_value.fetch(key, nil)
394
+ sorted_msg_value[key] = val if val
395
+ end
396
+ txn['msg'] = [
397
+ {
398
+ "type" => msg['type'],
399
+ "value" => sorted_msg_value
400
+ }
401
+ ]
402
+ txn
403
+ end
404
+
405
+ def get_response_error(response)
406
+ error = response['error']
407
+ return APIError.new(error) if error
408
+ end
409
+ end
410
+
411
+ def self.base64_encode(b)
412
+ Base64.strict_encode64 b
413
+ end
414
+
415
+ def self.hex_to_bin(h)
416
+ Secp256k1::Utils.decode_hex h
417
+ end
418
+
419
+ def self.bin_to_hex(b)
420
+ Secp256k1::Utils.encode_hex b
421
+ end
422
+
423
+ def self.hex_to_ascii(h)
424
+ [h].pack('H*')
425
+ end
426
+
427
+ def self.make_random_string(size)
428
+ SecureRandom.alphanumeric size
429
+ end
430
+
431
+ def self.json_dumps(h)
432
+ JSON.dump h
433
+ # h.to_json
434
+ # Hash[*h.sort.flatten].to_json
435
+ end
436
+ end
metadata ADDED
@@ -0,0 +1,85 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: bluzelle
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - vbstreetz
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2020-04-20 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: ecdsa
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: 1.2.0
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: 1.2.0
27
+ - !ruby/object:Gem::Dependency
28
+ name: bip_mnemonic
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: 0.0.4
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: 0.0.4
41
+ - !ruby/object:Gem::Dependency
42
+ name: dotenv
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ description: Ruby gem client library for the Bluzelle Service
56
+ email: vbstreetz@gmail.com
57
+ executables: []
58
+ extensions: []
59
+ extra_rdoc_files: []
60
+ files:
61
+ - lib/bluzelle.rb
62
+ homepage: https://rubygemspec.org/gems/bluzelle
63
+ licenses:
64
+ - MIT
65
+ metadata: {}
66
+ post_install_message:
67
+ rdoc_options: []
68
+ require_paths:
69
+ - lib
70
+ required_ruby_version: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ required_rubygems_version: !ruby/object:Gem::Requirement
76
+ requirements:
77
+ - - ">="
78
+ - !ruby/object:Gem::Version
79
+ version: '0'
80
+ requirements: []
81
+ rubygems_version: 3.1.2
82
+ signing_key:
83
+ specification_version: 4
84
+ summary: Ruby gem client library for the Bluzelle Service
85
+ test_files: []