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.
- checksums.yaml +7 -0
- data/lib/bluzelle.rb +436 -0
- metadata +85 -0
checksums.yaml
ADDED
@@ -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
|
data/lib/bluzelle.rb
ADDED
@@ -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: []
|