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