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.
@@ -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