bluzelle 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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: []