blzrb 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/.gitignore +11 -0
- data/.rspec +3 -0
- data/.travis.yml +6 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/Gemfile +10 -0
- data/Gemfile.lock +105 -0
- data/LICENSE.txt +21 -0
- data/README.md +56 -0
- data/Rakefile +6 -0
- data/bluzelle.gemspec +36 -0
- data/examples/main.rb +153 -0
- data/lib/bluzelle.rb +8 -0
- data/lib/bluzelle/constants.rb +12 -0
- data/lib/bluzelle/error.rb +14 -0
- data/lib/bluzelle/swarm/client.rb +442 -0
- data/lib/bluzelle/swarm/cosmos.rb +209 -0
- data/lib/bluzelle/swarm/request.rb +50 -0
- data/lib/bluzelle/swarm/transaction.rb +32 -0
- data/lib/bluzelle/utils.rb +185 -0
- data/lib/bluzelle/version.rb +3 -0
- metadata +151 -0
@@ -0,0 +1,209 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'rest-client'
|
4
|
+
require 'json'
|
5
|
+
require 'bluzelle/utils'
|
6
|
+
require 'bluzelle/constants'
|
7
|
+
|
8
|
+
module Bluzelle
|
9
|
+
module Swarm
|
10
|
+
class Cosmos
|
11
|
+
include Bluzelle::Constants
|
12
|
+
include Bluzelle::Utils
|
13
|
+
|
14
|
+
attr_reader :mnemonic, :endpoint, :address, :chain_id
|
15
|
+
attr_accessor :account_info
|
16
|
+
|
17
|
+
def initialize(options = {})
|
18
|
+
@mnemonic = options[:mnemonic]
|
19
|
+
@chain_id = options[:chain_id]
|
20
|
+
@endpoint = options[:endpoint]
|
21
|
+
@account_info = {}
|
22
|
+
|
23
|
+
@private_key = get_ec_private_key(@mnemonic)
|
24
|
+
@address = address_from_mnemonic
|
25
|
+
|
26
|
+
account
|
27
|
+
end
|
28
|
+
|
29
|
+
def query(endpoint)
|
30
|
+
Request.execute(method: 'get', url: "#{@endpoint}/#{endpoint}")
|
31
|
+
end
|
32
|
+
|
33
|
+
def send_transaction(method, endpoint, data, gas_info)
|
34
|
+
txn = Transaction.new(method, endpoint, data)
|
35
|
+
txn.set_gas(gas_info)
|
36
|
+
|
37
|
+
# fetch skeleton
|
38
|
+
skeleton = fetch_txn_skeleton(txn)
|
39
|
+
# set gas
|
40
|
+
skeleton = update_gas(txn, skeleton)
|
41
|
+
# sort
|
42
|
+
skeleton = sort_hash(skeleton)
|
43
|
+
|
44
|
+
broadcast_transaction(Transaction.new('post', TX_COMMAND, skeleton))
|
45
|
+
end
|
46
|
+
|
47
|
+
private
|
48
|
+
|
49
|
+
# Account query
|
50
|
+
def account
|
51
|
+
url = "#{@endpoint}/auth/accounts/#{@address}"
|
52
|
+
res = Request.execute(method: 'get', url: url)
|
53
|
+
|
54
|
+
set_account_details(res.dig('result', 'value'))
|
55
|
+
end
|
56
|
+
|
57
|
+
# Broadcasts a transaction
|
58
|
+
#
|
59
|
+
# @param [Bluzelle::Swarm::Transaction] txn
|
60
|
+
def broadcast_transaction(txn)
|
61
|
+
txn.data['memo'] = make_random_string
|
62
|
+
|
63
|
+
txn.data['signatures'] = [{
|
64
|
+
'account_number' => @account_info['account_number'].to_s,
|
65
|
+
'pub_key' => {
|
66
|
+
'type' => 'tendermint/PubKeySecp256k1',
|
67
|
+
'value' => to_base64(
|
68
|
+
[compressed_pub_key(open_key(@private_key))].pack('H*')
|
69
|
+
)
|
70
|
+
},
|
71
|
+
'sequence' => @account_info['sequence'].to_s,
|
72
|
+
'signature' => sign_transaction(txn.data)
|
73
|
+
}]
|
74
|
+
|
75
|
+
url = "#{@endpoint}/#{txn.endpoint}"
|
76
|
+
payload = { 'mode' => 'block', 'tx' => txn.data }
|
77
|
+
|
78
|
+
res = Request.execute(method: txn.method, url: url, payload: payload)
|
79
|
+
|
80
|
+
if res.dig('code').nil?
|
81
|
+
update_sequence
|
82
|
+
decode_json(hex_to_bin(res.dig('data'))) if res.key?('data')
|
83
|
+
else
|
84
|
+
handle_broadcast_error(res.dig('raw_log'), txn)
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
# Updates account sequence and retries broadcast
|
89
|
+
#
|
90
|
+
# @param [Bluzelle::Swarm::Transaction] txn
|
91
|
+
def update_account_sequence(txn)
|
92
|
+
if txn.retries_left != 0
|
93
|
+
account
|
94
|
+
retry_broadcast(txn)
|
95
|
+
else
|
96
|
+
raise Error::ApiError, 'Invalid chain id'
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
# Fetch transaction skeleton
|
101
|
+
def fetch_txn_skeleton(txn)
|
102
|
+
url = "#{@endpoint}/#{txn.endpoint}"
|
103
|
+
|
104
|
+
data = Request.execute(
|
105
|
+
method: txn.method,
|
106
|
+
url: url,
|
107
|
+
payload: txn.data,
|
108
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
|
109
|
+
)
|
110
|
+
|
111
|
+
data['value']
|
112
|
+
end
|
113
|
+
|
114
|
+
# Check if address and mnemonic are valid
|
115
|
+
def address_from_mnemonic
|
116
|
+
pub_key = get_ec_public_key_from_priv(@private_key)
|
117
|
+
get_address(pub_key)
|
118
|
+
end
|
119
|
+
|
120
|
+
# Updates account details
|
121
|
+
#
|
122
|
+
# @param [Hash] data
|
123
|
+
def set_account_details(data)
|
124
|
+
account_number = data.dig('account_number')
|
125
|
+
sequence = data.dig('sequence')
|
126
|
+
|
127
|
+
@account_info['account_number'] = account_number
|
128
|
+
|
129
|
+
if @account_info['sequence'] != sequence
|
130
|
+
@account_info['sequence'] = sequence
|
131
|
+
return true
|
132
|
+
end
|
133
|
+
|
134
|
+
false
|
135
|
+
end
|
136
|
+
|
137
|
+
# Retry broadcast after failure
|
138
|
+
#
|
139
|
+
# @param [Bluzelle::Swarm::Transaction]
|
140
|
+
def retry_broadcast(txn)
|
141
|
+
txn.retries_left -= 1
|
142
|
+
|
143
|
+
sleep BROADCAST_RETRY_SECONDS
|
144
|
+
|
145
|
+
broadcast_transaction(txn)
|
146
|
+
end
|
147
|
+
|
148
|
+
# Handle broadcast error
|
149
|
+
#
|
150
|
+
# @param [String] raw_log
|
151
|
+
# @param [Bluzelle::Swarm::Transaction] txn
|
152
|
+
def handle_broadcast_error(raw_log, txn)
|
153
|
+
if raw_log.include?('signature verification failed')
|
154
|
+
update_account_sequence(txn)
|
155
|
+
else
|
156
|
+
raise Error::ApiError, extract_error_message(raw_log)
|
157
|
+
end
|
158
|
+
end
|
159
|
+
|
160
|
+
# Update account sequence
|
161
|
+
def update_sequence
|
162
|
+
@account_info['sequence'] = @account_info['sequence'].to_i + 1
|
163
|
+
end
|
164
|
+
|
165
|
+
# Signs a transaction
|
166
|
+
#
|
167
|
+
# @param txn
|
168
|
+
def sign_transaction(txn)
|
169
|
+
payload = {
|
170
|
+
'account_number' => @account_info['account_number'].to_s,
|
171
|
+
'chain_id' => @chain_id,
|
172
|
+
'fee' => txn['fee'],
|
173
|
+
'memo' => txn['memo'],
|
174
|
+
'msgs' => txn['msg'],
|
175
|
+
'sequence' => @account_info['sequence'].to_s
|
176
|
+
}
|
177
|
+
|
178
|
+
to_base64(ecdsa_sign(encode_json(payload), @private_key))
|
179
|
+
end
|
180
|
+
|
181
|
+
def update_gas(txn, data)
|
182
|
+
res = data.clone
|
183
|
+
|
184
|
+
if res.dig('fee', 'gas').to_i > txn.max_gas && txn.max_gas != 0
|
185
|
+
res['fee']['gas'] = txn.max_gas.to_s
|
186
|
+
end
|
187
|
+
|
188
|
+
if !txn.max_fee.nil?
|
189
|
+
res['fee']['amount'] = [{
|
190
|
+
'denom': TOKEN_NAME,
|
191
|
+
'amount': txn.max_fee.to_s
|
192
|
+
}]
|
193
|
+
elsif !txn.gas_price.nil?
|
194
|
+
res['fee']['amount'] = [{
|
195
|
+
'denom': TOKEN_NAME,
|
196
|
+
'amount': (res['fee']['gas'] * txn.gas_price).to_s
|
197
|
+
}]
|
198
|
+
end
|
199
|
+
|
200
|
+
res
|
201
|
+
end
|
202
|
+
|
203
|
+
def update_memo(txn)
|
204
|
+
txn['memo'] = make_random_string
|
205
|
+
txn
|
206
|
+
end
|
207
|
+
end
|
208
|
+
end
|
209
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'rest-client'
|
4
|
+
|
5
|
+
module Bluzelle
|
6
|
+
module Swarm
|
7
|
+
class Request
|
8
|
+
class << self
|
9
|
+
def execute(options = {})
|
10
|
+
resp = RestClient::Request.execute(parse_options(options))
|
11
|
+
rescue RestClient::ExceptionWithResponse => e
|
12
|
+
case e.http_code
|
13
|
+
when 404, 500
|
14
|
+
raise Error::ApiError, e.response.body
|
15
|
+
else
|
16
|
+
error(e)
|
17
|
+
end
|
18
|
+
else
|
19
|
+
success(resp)
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
def parse_options(options = {})
|
25
|
+
{
|
26
|
+
method: options[:method],
|
27
|
+
url: options[:url],
|
28
|
+
payload: JSON.generate(options[:payload]),
|
29
|
+
headers: options[:headers]
|
30
|
+
}
|
31
|
+
end
|
32
|
+
|
33
|
+
def error(err)
|
34
|
+
body = JSON.parse(err.response.body)
|
35
|
+
error_message = body.dig('error', 'message')
|
36
|
+
|
37
|
+
if error_message.is_a?(String)
|
38
|
+
raise Error::ApiError, error_message
|
39
|
+
else
|
40
|
+
raise Error::ApiError, 'error occurred'
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def success(resp)
|
45
|
+
JSON.parse(resp.body)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Bluzelle
|
4
|
+
module Swarm
|
5
|
+
class Transaction
|
6
|
+
attr_reader :method, :endpoint, :data
|
7
|
+
attr_accessor :gas_price, :max_gas, :max_fee, :memo, :retries_left
|
8
|
+
|
9
|
+
def initialize(method, endpoint, data)
|
10
|
+
@method = method
|
11
|
+
@endpoint = endpoint
|
12
|
+
@data = data
|
13
|
+
@gas_price = 0
|
14
|
+
@max_gas = 0
|
15
|
+
@max_fee = 0
|
16
|
+
@retries_left = 10
|
17
|
+
end
|
18
|
+
|
19
|
+
def set_gas(gas_info)
|
20
|
+
return if gas_info.nil? || !gas_info.is_a?(Hash)
|
21
|
+
|
22
|
+
gas_info = Utils.stringify_keys(gas_info)
|
23
|
+
|
24
|
+
@gas_price = gas_info['gas_price'].to_i if gas_info.key?('gas_price')
|
25
|
+
|
26
|
+
@max_gas = gas_info['max_gas'].to_i if gas_info.key?('max_gas')
|
27
|
+
|
28
|
+
@max_fee = gas_info['max_fee'].to_i if gas_info.key?('max_fee')
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,185 @@
|
|
1
|
+
require 'bip_mnemonic'
|
2
|
+
require 'bitcoin'
|
3
|
+
require 'money-tree'
|
4
|
+
require 'digest'
|
5
|
+
require 'openssl'
|
6
|
+
require 'base64'
|
7
|
+
require 'secp256k1'
|
8
|
+
|
9
|
+
module Bluzelle
|
10
|
+
module Utils
|
11
|
+
module_function
|
12
|
+
|
13
|
+
def get_ec_private_key(mnemonic)
|
14
|
+
seed = bip39_mnemonic_to_seed(mnemonic)
|
15
|
+
node = bip32_from_seed(seed)
|
16
|
+
child = node.node_for_path(Constants::PATH)
|
17
|
+
ec_pair = create_ec_pair(child.private_key.to_hex)
|
18
|
+
ec_pair.priv
|
19
|
+
end
|
20
|
+
|
21
|
+
def get_ec_public_key_from_priv(priv)
|
22
|
+
key = open_key(priv)
|
23
|
+
compressed_pub_key(key)
|
24
|
+
end
|
25
|
+
|
26
|
+
def validate_address(address, mnemonic)
|
27
|
+
priv_key = get_ec_private_key(mnemonic)
|
28
|
+
pub_key = get_ec_public_key_from_priv(priv_key)
|
29
|
+
|
30
|
+
if get_address(pub_key) != address
|
31
|
+
raise ArgumentError, 'Bad credentials - verify your address and mnemonic'
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def get_address(pub_key)
|
36
|
+
hash = rmd_160_digest(sha_256_digest([pub_key].pack('H*')))
|
37
|
+
word = bech32_convert_bits(to_bytes(hash))
|
38
|
+
bech32_encode(Constants::PREFIX, word)
|
39
|
+
end
|
40
|
+
|
41
|
+
def to_bytes(obj)
|
42
|
+
obj.bytes
|
43
|
+
end
|
44
|
+
|
45
|
+
def rmd_160_digest(hex)
|
46
|
+
Digest::RMD160.digest hex
|
47
|
+
end
|
48
|
+
|
49
|
+
def sha_256_digest(hex)
|
50
|
+
Digest::SHA256.digest hex
|
51
|
+
end
|
52
|
+
|
53
|
+
def bech32_encode(prefix, word)
|
54
|
+
Bitcoin::Bech32.encode(prefix, word)
|
55
|
+
end
|
56
|
+
|
57
|
+
def bech32_convert_bits(bytes, from_bits: 8, to_bits: 5, pad: false)
|
58
|
+
Bitcoin::Bech32.convert_bits(bytes, from_bits: from_bits, to_bits: to_bits, pad: pad)
|
59
|
+
end
|
60
|
+
|
61
|
+
def bip39_mnemonic_to_seed(mnemonic)
|
62
|
+
BipMnemonic.to_seed(mnemonic: mnemonic)
|
63
|
+
end
|
64
|
+
|
65
|
+
def open_key(priv)
|
66
|
+
group = OpenSSL::PKey::EC::Group.new('secp256k1')
|
67
|
+
key = OpenSSL::PKey::EC.new(group)
|
68
|
+
|
69
|
+
key.private_key = OpenSSL::BN.new(priv, 16)
|
70
|
+
key.public_key = group.generator.mul(key.private_key)
|
71
|
+
|
72
|
+
key
|
73
|
+
end
|
74
|
+
|
75
|
+
def compressed_pub_key(key)
|
76
|
+
public_key = key.public_key
|
77
|
+
public_key.group.point_conversion_form = :compressed
|
78
|
+
public_key.to_hex.rjust(66, '0')
|
79
|
+
end
|
80
|
+
|
81
|
+
def bip32_from_seed(seed)
|
82
|
+
MoneyTree::Master.new(seed_hex: seed)
|
83
|
+
end
|
84
|
+
|
85
|
+
def create_ec_pair(private_key)
|
86
|
+
Bitcoin::Key.new(private_key, nil, { compressed: false })
|
87
|
+
end
|
88
|
+
|
89
|
+
def make_random_string(length = 32)
|
90
|
+
random_string = ''
|
91
|
+
chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'.chars
|
92
|
+
|
93
|
+
1.upto(length) { random_string << chars.sample }
|
94
|
+
|
95
|
+
random_string
|
96
|
+
end
|
97
|
+
|
98
|
+
def to_base64(str)
|
99
|
+
Base64.strict_encode64(str)
|
100
|
+
end
|
101
|
+
|
102
|
+
def convert_lease(lease)
|
103
|
+
return '0' if lease.nil?
|
104
|
+
|
105
|
+
seconds = 0
|
106
|
+
|
107
|
+
seconds += lease.dig(:days).nil? ? 0 : (lease.dig(:days).to_i * 24 * 60 * 60)
|
108
|
+
seconds += lease.dig(:hours).nil? ? 0 : (lease.dig(:hours).to_i * 60 * 60)
|
109
|
+
seconds += lease.dig(:minutes).nil? ? 0 : (lease.dig(:minutes).to_i * 60)
|
110
|
+
seconds += lease.dig(:seconds).nil? ? 0 : lease.dig(:seconds).to_i
|
111
|
+
|
112
|
+
(seconds / Constants::BLOCK_TIME_IN_SECONDS).to_s
|
113
|
+
end
|
114
|
+
|
115
|
+
def sort_hash(hash)
|
116
|
+
hash_clone = hash.clone
|
117
|
+
|
118
|
+
hash_clone.each do |key, value|
|
119
|
+
hash_clone[key] = sort_hash(value) if value.is_a?(Hash)
|
120
|
+
|
121
|
+
next unless value.is_a?(Array)
|
122
|
+
|
123
|
+
arr = []
|
124
|
+
|
125
|
+
hash_clone[key].each do |el|
|
126
|
+
arr << sort_hash(el)
|
127
|
+
end
|
128
|
+
|
129
|
+
hash_clone[key] = arr
|
130
|
+
end
|
131
|
+
|
132
|
+
hash_clone.sort.to_h
|
133
|
+
end
|
134
|
+
|
135
|
+
def stringify_keys(hash)
|
136
|
+
res = {}
|
137
|
+
|
138
|
+
hash.each do |key, value|
|
139
|
+
if value.is_a?(Hash)
|
140
|
+
res[key.to_s] = stringify_keys(value)
|
141
|
+
next
|
142
|
+
end
|
143
|
+
res[key.to_s] = value
|
144
|
+
end
|
145
|
+
|
146
|
+
res
|
147
|
+
end
|
148
|
+
|
149
|
+
def ecdsa_sign(payload, private_key)
|
150
|
+
pk = Secp256k1::PrivateKey.new(privkey: hex_to_bin(private_key), raw: true)
|
151
|
+
rs = pk.ecdsa_sign(payload)
|
152
|
+
r = rs.slice(0, 32).read_string.reverse
|
153
|
+
s = rs.slice(32, 32).read_string.reverse
|
154
|
+
"#{r}#{s}"
|
155
|
+
end
|
156
|
+
|
157
|
+
def encode_json(obj)
|
158
|
+
JSON.generate(obj)
|
159
|
+
end
|
160
|
+
|
161
|
+
def decode_json(str)
|
162
|
+
JSON.parse(str)
|
163
|
+
end
|
164
|
+
|
165
|
+
def hex_to_bin(hex_str)
|
166
|
+
[hex_str].pack('H*')
|
167
|
+
end
|
168
|
+
|
169
|
+
def extract_error_message(str)
|
170
|
+
offset1 = str.index(': ')
|
171
|
+
|
172
|
+
return str if offset1.nil?
|
173
|
+
|
174
|
+
prefix = str.slice(0, offset1)
|
175
|
+
|
176
|
+
case prefix
|
177
|
+
when 'insufficient fee'
|
178
|
+
return str.slice(offset1 + 2, str.length)
|
179
|
+
end
|
180
|
+
|
181
|
+
offset2 = str.index(': ', offset1 + 1)
|
182
|
+
str[(offset1 + 2)..(offset2 - 1)]
|
183
|
+
end
|
184
|
+
end
|
185
|
+
end
|