steem-ruby 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,36 @@
1
+ module Steem
2
+ module ChainConfig
3
+ EXPIRE_IN_SECS = 600
4
+ EXPIRE_IN_SECS_PROPOSAL = 24 * 60 * 60
5
+
6
+ NETWORKS_STEEM_CHAIN_ID = '0000000000000000000000000000000000000000000000000000000000000000'
7
+ NETWORKS_STEEM_ADDRESS_PREFIX = 'STM'
8
+ NETWORKS_STEEM_CORE_ASSET = ["0", 3, "@@000000021"] # STEEM
9
+ NETWORKS_STEEM_DEBT_ASSET = ["0", 3, "@@000000013"] # SBD
10
+ NETWORKS_STEEM_VEST_ASSET = ["0", 6, "@@000000037"] # VESTS
11
+ NETWORKS_STEEM_DEFAULT_NODE = 'https://api.steemit.com' # √
12
+ # NETWORKS_STEEM_DEFAULT_NODE = 'https://api.steemitstage.com' # √
13
+ # NETWORKS_STEEM_DEFAULT_NODE = 'https://api.steemitdev.com' # √
14
+ # NETWORKS_STEEM_DEFAULT_NODE = 'https://appbasetest.timcliff.com'
15
+ # NETWORKS_STEEM_DEFAULT_NODE = 'https://gtg.steem.house:8090'
16
+ # NETWORKS_STEEM_DEFAULT_NODE = 'https://api.steem.house' # √?
17
+ # NETWORKS_STEEM_DEFAULT_NODE = 'https://seed.bitcoiner.me'
18
+ # NETWORKS_STEEM_DEFAULT_NODE = 'https://steemd.minnowsupportproject.org'
19
+ # NETWORKS_STEEM_DEFAULT_NODE = 'https://steemd.privex.io'
20
+ # NETWORKS_STEEM_DEFAULT_NODE = 'https://rpc.steemliberator.com'
21
+ # NETWORKS_STEEM_DEFAULT_NODE = 'https://rpc.curiesteem.com'
22
+ # NETWORKS_STEEM_DEFAULT_NODE = 'https://rpc.buildteam.io'
23
+ # NETWORKS_STEEM_DEFAULT_NODE = 'https://steemd.pevo.science'
24
+ # NETWORKS_STEEM_DEFAULT_NODE = 'https://rpc.steemviz.com'
25
+ # NETWORKS_STEEM_DEFAULT_NODE = 'https://steemd.steemgigs.org'
26
+
27
+ NETWORKS_TEST_CHAIN_ID = '46d82ab7d8db682eb1959aed0ada039a6d49afa1602491f93dde9cac3e8e6c32'
28
+ NETWORKS_TEST_ADDRESS_PREFIX = 'TST'
29
+ NETWORKS_TEST_CORE_ASSET = ["0", 3, "@@000000021"] # TESTS
30
+ NETWORKS_TEST_DEBT_ASSET = ["0", 3, "@@000000013"] # TBD
31
+ NETWORKS_TEST_VEST_ASSET = ["0", 6, "@@000000037"] # VESTS
32
+ NETWORKS_TEST_DEFAULT_NODE = 'https://testnet.steemitdev.com'
33
+
34
+ NETWORK_CHAIN_IDS = [NETWORKS_STEEM_CHAIN_ID, NETWORKS_TEST_CHAIN_ID]
35
+ end
36
+ end
@@ -0,0 +1,14 @@
1
+ module Steem
2
+ class Formatter
3
+ def self.reputation(raw)
4
+ raw = raw.to_i
5
+ neg = raw < 0
6
+ level = Math.log10(raw.abs)
7
+ level = [level - 9, 0].max
8
+ level = (neg ? -1 : 1) * level
9
+ level = (level * 9) + 25
10
+
11
+ level.round(1)
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,98 @@
1
+ module Steem
2
+ # {Jsonrpc} allows you to inspect the available methods offered by a node.
3
+ # If a node runs a plugin you want, then all of the API methods it can exposes
4
+ # will automatically be available. This API is used internally to determine
5
+ # which APIs and methods are available on the node you specify.
6
+ #
7
+ # In theory, if a new plugin is created and enabled by the node, it will be
8
+ # available by this library without needing an update to its code.
9
+ class Jsonrpc < Api
10
+ API_METHODS = %i(get_signature get_methods)
11
+
12
+ def self.api_methods
13
+ @api_methods ||= {}
14
+ end
15
+
16
+ # Might help diagnose a cluster that has asymmetric plugin definitions.
17
+ def self.reset_api_methods
18
+ @api_methods = nil
19
+ end
20
+
21
+ def initialize(options = {})
22
+ self.class.api_name = :jsonrpc
23
+ @methods = API_METHODS
24
+ super
25
+ end
26
+
27
+ def get_api_methods(&block)
28
+ api_methods = self.class.api_methods[@rpc_client.uri.to_s]
29
+
30
+ if api_methods.nil?
31
+ get_methods do |result, error, rpc_id|
32
+ methods = result.map do |method|
33
+ method.split('.').map(&:to_sym)
34
+ end
35
+
36
+ api_methods = Hashie::Mash.new
37
+
38
+ methods.each do |api, method|
39
+ api_methods[api] ||= []
40
+ api_methods[api] << method
41
+ end
42
+
43
+ self.class.api_methods[@rpc_client.uri.to_s] = api_methods
44
+ end
45
+ end
46
+
47
+ if !!block
48
+ api_methods.each do |api, methods|
49
+ yield api, methods
50
+ end
51
+ else
52
+ return api_methods
53
+ end
54
+ end
55
+
56
+ def get_all_signatures(&block)
57
+ request_body = []
58
+ method_names = []
59
+ method_map = {}
60
+ signatures = {}
61
+ offset = 0
62
+
63
+ get_api_methods do |api, methods|
64
+ request_body += methods.map do |method|
65
+ method_name = "#{api}.#{method}"
66
+ method_names << method_name
67
+ current_rpc_id = @rpc_client.rpc_id
68
+ offset += 1
69
+ method_map[current_rpc_id] = [api, method]
70
+
71
+ {
72
+ jsonrpc: '2.0',
73
+ id: current_rpc_id,
74
+ method: 'jsonrpc.get_signature',
75
+ params: {method: method_name}
76
+ }
77
+ end
78
+ end
79
+
80
+ @rpc_client.rpc_post(nil, nil, {request_body: request_body}) do |result, error, id|
81
+ api, method = method_map[id]
82
+ api = api.to_sym
83
+ method = method.to_sym
84
+
85
+ signatures[api] ||= {}
86
+ signatures[api][method] = result
87
+ end
88
+
89
+ if !!block
90
+ signatures.each do |api, methods|
91
+ yield api, methods
92
+ end
93
+ else
94
+ return signatures
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,56 @@
1
+ module Steem
2
+ module Retriable
3
+ # @private
4
+ MAX_RETRY_COUNT = 100
5
+
6
+ # @private
7
+ MAX_BACKOFF = 30
8
+
9
+ MAX_RETRY_ELAPSE = 300
10
+
11
+ RETRYABLE_EXCEPTIONS = [
12
+ NonCanonicalSignatureError, IncorrectRequestIdError,
13
+ IncorrectResponseIdError, Errno::EBADF, Errno::ECONNREFUSED,
14
+ JSON::ParserError, IOError, Net::OpenTimeout
15
+ ]
16
+
17
+ # Expontential backoff.
18
+ #
19
+ # @private
20
+ def backoff
21
+ @backoff ||= 0.1
22
+ @backoff *= 2
23
+ if @backoff > MAX_BACKOFF
24
+ @backoff = 0.1
25
+
26
+ if Time.now.utc - @first_retry_at > MAX_RETRY_ELAPSE
27
+ @retry_count = nil
28
+ @first_retry_at = nil
29
+ end
30
+ end
31
+
32
+ sleep @backoff
33
+ end
34
+
35
+ def can_retry?(e = nil)
36
+ @retry_count ||= 0
37
+ @first_retry_at ||= Time.now.utc
38
+
39
+ return false if @retry_count >= MAX_RETRY_COUNT
40
+
41
+ @retry_count += 1
42
+
43
+ can_retry = case e
44
+ when *RETRYABLE_EXCEPTIONS
45
+ true
46
+ when RemoteNodeError
47
+ e.inspect.include?('Unable to acquire database lock')
48
+ else; false
49
+ end
50
+
51
+ backoff if can_retry
52
+
53
+ can_retry
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,154 @@
1
+ module Steem
2
+ module RPC
3
+ class BaseClient
4
+ include ChainConfig
5
+
6
+ attr_accessor :chain, :error_pipe
7
+
8
+ # @private
9
+ POST_HEADERS = {
10
+ 'Content-Type' => 'application/json; charset=utf-8',
11
+ 'User-Agent' => Steem::AGENT_ID
12
+ }
13
+
14
+ def initialize(options = {})
15
+ @chain = options[:chain] || :steem
16
+ @error_pipe = options[:error_pipe] || STDERR
17
+ @api_name = options[:api_name]
18
+ @url = case @chain
19
+ when :steem then options[:url] || NETWORKS_STEEM_DEFAULT_NODE
20
+ when :test then options[:url] || NETWORKS_TEST_DEFAULT_NODE
21
+ else; raise UnsupportedChainError, "Unsupported chain: #{@chain}"
22
+ end
23
+ end
24
+
25
+ def uri
26
+ @uri ||= URI.parse(@url)
27
+ end
28
+
29
+ def http
30
+ @http ||= Net::HTTP.new(uri.host, uri.port).tap do |http|
31
+ http.use_ssl = true
32
+ http.keep_alive_timeout = 2 # seconds
33
+
34
+ # WARNING This method opens a serious security hole. Never use this
35
+ # method in production code.
36
+ # http.set_debug_output(STDOUT) if !!ENV['DEBUG']
37
+ end
38
+ end
39
+
40
+ def http_post
41
+ @http_post ||= Net::HTTP::Post.new(uri.request_uri, POST_HEADERS)
42
+ end
43
+
44
+ def put(api_name = @api_name, api_method = nil, options = {})
45
+ current_rpc_id = rpc_id
46
+ rpc_method_name = "#{api_name}.#{api_method}"
47
+ options ||= {}
48
+ request_body = defined?(options.delete) ? options.delete(:request_body) : []
49
+ request_body ||= []
50
+
51
+ request_body << {
52
+ jsonrpc: '2.0',
53
+ id: current_rpc_id,
54
+ method: rpc_method_name,
55
+ params: options
56
+ }
57
+
58
+ request_body
59
+ end
60
+
61
+ def evaluate_id(options = {})
62
+ debug = options[:debug] || ENV['DEBUG'] == 'true'
63
+ request = options[:request]
64
+ response = options[:response]
65
+ api_method = options[:api_method]
66
+ req_id = request[:id].to_i
67
+ res_id = !!response['id'] ? response['id'].to_i : nil
68
+ method = [@api_name, api_method].join('.')
69
+
70
+ if debug
71
+ req = JSON.pretty_generate(request)
72
+ res = JSON.parse(response) rescue response
73
+ res = JSON.pretty_generate(response) rescue response
74
+
75
+ error_pipe.puts '=' * 80
76
+ error_pipe.puts "Request:"
77
+ error_pipe.puts req
78
+ error_pipe.puts '=' * 80
79
+ error_pipe.puts "Response:"
80
+ error_pipe.puts res
81
+ error_pipe.puts '=' * 80
82
+ error_pipe.puts Thread.current.backtrace.join("\n")
83
+ end
84
+
85
+ error = response['error'].to_json if !!response['error']
86
+
87
+ if req_id != res_id
88
+ raise IncorrectResponseIdError, "#{method}: The json-rpc id did not match. Request was: #{req_id}, got: #{res_id.inspect}", error.nil? ? nil : error.to_json
89
+ end
90
+ end
91
+
92
+ def http_request(request)
93
+ http.request(request)
94
+ end
95
+
96
+ def rpc_post(api_name = @api_name, api_method = nil, options = {}, &block)
97
+ request = http_post
98
+
99
+ request_body = if !!api_name && !!api_method
100
+ put(api_name, api_method, options)
101
+ elsif !!options && defined?(options.delete)
102
+ options.delete(:request_body)
103
+ end
104
+
105
+ request.body = if request_body.size == 1
106
+ request_body.first.to_json
107
+ else
108
+ request_body.to_json
109
+ end
110
+
111
+ response = http_request(request)
112
+
113
+ case response.code
114
+ when '200'
115
+ response = JSON[response.body]
116
+ response = case response
117
+ when Hash
118
+ Hashie::Mash.new(response).tap do |r|
119
+ evaluate_id(request: request_body.first, response: r, api_method: api_method)
120
+ end
121
+ when Array
122
+ Hashie::Array.new(response).tap do |r|
123
+ request_body.each_with_index do |req, index|
124
+ evaluate_id(request: req, response: r[index], api_method: api_method)
125
+ end
126
+ end
127
+ else; response
128
+ end
129
+
130
+ if !!block
131
+ case response
132
+ when Hashie::Mash then yield response.result, response.error, response.id
133
+ when Hashie::Array
134
+ response.each do |r|
135
+ r = Hashie::Mash.new(r)
136
+ yield r.result, r.error, r.id
137
+ end
138
+ else; yield response
139
+ end
140
+ else
141
+ return response
142
+ end
143
+ else
144
+ raise UnknownError, "#{api_name}.#{api_method}: #{response.body}"
145
+ end
146
+ end
147
+
148
+ def rpc_id
149
+ @rpc_id ||= 0
150
+ @rpc_id += 1
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,23 @@
1
+ module Steem
2
+ module RPC
3
+ # {ThreadSafeClient} is the default RPC Client used by `steem-ruby.` It's
4
+ # perfect for simple requests. But for higher performance, it's better to
5
+ # override {BaseClient} and implement something other than {Net::HTTP}.
6
+ class ThreadSafeClient < BaseClient
7
+ SEMAPHORE = Mutex.new.freeze
8
+
9
+ def http_request(request)
10
+ response = SEMAPHORE.synchronize do
11
+ http.request(request)
12
+ end
13
+ end
14
+
15
+ def rpc_id
16
+ SEMAPHORE.synchronize do
17
+ @rpc_id ||= 0
18
+ @rpc_id += 1
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,266 @@
1
+ module Steem
2
+ # {TransactionBuilder} can be used to create a transaction that the
3
+ # {NetworkBroadcastApi} can broadcast to the rest of the platform. The main
4
+ # feature of this class is the ability to cryptographically sign the
5
+ # transaction so that it conforms to the consensus rules that are required by
6
+ # the blockchain.
7
+ #
8
+ # wif = '5JrvPrQeBBvCRdjv29iDvkwn3EQYZ9jqfAHzrCyUvfbEbRkrYFC'
9
+ # builder = Steem::TransactionBuilder.new(wif: wif)
10
+ # builder.put(vote: {
11
+ # voter: 'alice',
12
+ # author: 'bob',
13
+ # permlink: 'my-burgers',
14
+ # weight: 10000
15
+ # })
16
+ #
17
+ # trx = builder.transaction
18
+ # network_broadcast_api = Steem::NetworkBroadcastApi.new
19
+ # network_broadcast_api.broadcast_transaction_synchronous(trx: trx)
20
+ #
21
+ class TransactionBuilder
22
+ include Retriable
23
+ include ChainConfig
24
+ include Utils
25
+
26
+ attr_accessor :database_api, :block_api, :wif, :expiration, :operations
27
+
28
+ def initialize(options = {})
29
+ @database_api = options[:database_api] || Steem::DatabaseApi.new(options)
30
+ @block_api = options[:block_api] || Steem::BlockApi.new(options)
31
+ @wif = options[:wif]
32
+ @ref_block_num = options[:ref_block_num]
33
+ @ref_block_prefix = options[:ref_block_prefix]
34
+ @expiration = nil
35
+ @operations = options[:operations] || []
36
+ @extensions = []
37
+ @signatures = []
38
+ @chain = options[:chain] || :steem
39
+ @error_pipe = options[:error_pipe] || STDERR
40
+ @chain_id = case @chain
41
+ when :steem then NETWORKS_STEEM_CHAIN_ID
42
+ when :test then NETWORKS_TEST_CHAIN_ID
43
+ else; raise UnsupportedChainError, "Unsupported chain: #{@chain}"
44
+ end
45
+ end
46
+
47
+ def inspect
48
+ properties = %w(
49
+ ref_block_num ref_block_prefix expiration operations
50
+ extensions signatures
51
+ ).map do |prop|
52
+ if !!(v = instance_variable_get("@#{prop}"))
53
+ "@#{prop}=#{v}"
54
+ end
55
+ end.compact.join(', ')
56
+
57
+ "#<#{self.class.name} [#{properties}]>"
58
+ end
59
+
60
+ def reset
61
+ @ref_block_num = nil
62
+ @ref_block_prefix = nil
63
+ @expiration = nil
64
+ @operations = []
65
+ @extensions = []
66
+ @signatures = []
67
+
68
+ self
69
+ end
70
+
71
+ def expired?
72
+ @expiration.nil? || @expiration < Time.now
73
+ end
74
+
75
+ # If the transaction can be prepared, this method will do so and set the
76
+ # expiration. Once the expiration is set, it will not re-prepare. If you
77
+ # call {#put}, the expiration is set {::Nil} so that it can be re-prepared.
78
+ #
79
+ # Usually, this method is called automatically by {#put} and/or {#transaction}.
80
+ #
81
+ # @return {TransactionBuilder}
82
+ def prepare
83
+ if expired?
84
+ catch :prepare_header do; begin
85
+ @database_api.get_dynamic_global_properties do |properties|
86
+ block_number = properties.last_irreversible_block_num
87
+
88
+ @block_api.get_block_header(block_num: block_number) do |result|
89
+ header = result.header
90
+
91
+ @ref_block_num = (block_number - 1) & 0xFFFF
92
+ @ref_block_prefix = unhexlify(header.previous[8..-1]).unpack('V*')[0]
93
+ @expiration = (Time.parse(properties.time + 'Z') + EXPIRE_IN_SECS).utc
94
+ end
95
+ end
96
+ rescue => e
97
+ if can_retry? e
98
+ @error_pipe.puts "#{e} ... retrying."
99
+ throw :prepare_header
100
+ else
101
+ raise e
102
+ end
103
+ end; end
104
+ end
105
+
106
+ self
107
+ end
108
+
109
+ # Sets operations all at once, then prepares.
110
+ def operations=(operations)
111
+ @operations = operations
112
+ prepare
113
+ @operations
114
+ end
115
+
116
+ # A quick and flexible way to append a new operation to the transaction.
117
+ # This method uses ducktyping to figure out how to form the operation.
118
+ #
119
+ # There are three main ways you can call this method. These assume that
120
+ # `op_type` is a {::Symbol} (or {::String}) representing the type of operation and `op` is the
121
+ # operation {::Hash}.
122
+ #
123
+ # put(op_type, op)
124
+ #
125
+ # ... or ...
126
+ #
127
+ # put(op_type => op)
128
+ #
129
+ # ... or ...
130
+ #
131
+ # put([op_type, op])
132
+ #
133
+ # You can also chain multiple operations:
134
+ #
135
+ # builder = Steem::TransactionBuilder.new
136
+ # builder.put(vote: vote1).put(vote: vote2)
137
+ # @return {TransactionBuilder}
138
+ def put(type, op = nil)
139
+ @expiration = nil
140
+
141
+ case type
142
+ when Symbol then @operations << [type, op]
143
+ when String then @operations << [type.to_sym, op]
144
+ when Hash then @operations << [type.keys.first.to_sym, type.values.first]
145
+ when Array then @operations << type
146
+ else
147
+ # don't know what to do with it, skipped
148
+ end
149
+
150
+ prepare
151
+
152
+ self
153
+ end
154
+
155
+ # If all of the required values are set, this returns a fully formed
156
+ # transaction that is ready to broadcast.
157
+ #
158
+ # @return
159
+ # {
160
+ # :ref_block_num => 18912,
161
+ # :ref_block_prefix => 575781536,
162
+ # :expiration => "2018-04-26T15:26:12",
163
+ # :extensions => [],
164
+ # :operations => [[:vote, {
165
+ # :voter => "alice",
166
+ # :author => "bob",
167
+ # :permlink => "my-burgers",
168
+ # :weight => 10000
169
+ # }
170
+ # ]],
171
+ # :signatures => ["1c45b65740b4b2c17c4bcf6bcc3f8d90ddab827d50532729fc3b8f163f2c465a532b0112ae4bf388ccc97b7c2e0bc570caadda78af48cf3c261037e65eefcd941e"]
172
+ # }
173
+ def transaction
174
+ prepare
175
+ sign
176
+ end
177
+
178
+ # Appends to the `signatures` array of the transaction, built from a
179
+ # serialized digest.
180
+ #
181
+ # @return {Hash | TransactionBuilder} The fully signed transaction if a `wif` is provided or the instance of the {TransactionBuilder} if a `wif` has not yet been provided.
182
+ def sign
183
+ return self unless !!@wif
184
+ return self if expired?
185
+
186
+ trx = {
187
+ ref_block_num: @ref_block_num,
188
+ ref_block_prefix: @ref_block_prefix,
189
+ expiration: @expiration.strftime('%Y-%m-%dT%H:%M:%S'),
190
+ operations: @operations,
191
+ extensions: @extensions,
192
+ signatures: @signatures
193
+ }
194
+
195
+ catch :serialize do; begin
196
+ @database_api.get_transaction_hex(trx: trx) do |result|
197
+ hex = @chain_id + result.hex[0..-4] # Why do we have to chop the last two bytes?
198
+ digest = unhexlify(hex)
199
+ digest_hex = Digest::SHA256.digest(digest)
200
+ private_key = Bitcoin::Key.from_base58 @wif
201
+ public_key_hex = private_key.pub
202
+ ec = Bitcoin::OpenSSL_EC
203
+ count = 0
204
+ sig = nil
205
+
206
+ loop do
207
+ count += 1
208
+ @error_pipe.puts "#{count} attempts to find canonical signature" if count % 40 == 0
209
+ sig = ec.sign_compact(digest_hex, private_key.priv, public_key_hex, false)
210
+
211
+ next if public_key_hex != ec.recover_compact(digest_hex, sig)
212
+ break if canonical? sig
213
+ end
214
+
215
+ trx[:signatures] = @signatures = [hexlify(sig)]
216
+ end
217
+ rescue => e
218
+ if can_retry? e
219
+ @error_pipe.puts "#{e} ... retrying."
220
+ throw :serialize
221
+ else
222
+ raise e
223
+ end
224
+ end; end
225
+
226
+ trx
227
+ end
228
+
229
+ # @return [Array] All public keys that could possibly sign for a given transaction.
230
+ def potential_signatures
231
+ @database_api.get_potential_signatures(trx: transaction) do |result|
232
+ result[:keys]
233
+ end
234
+ end
235
+
236
+ # This API will take a partially signed transaction and a set of public keys
237
+ # that the owner has the ability to sign for and return the minimal subset
238
+ # of public keys that should add signatures to the transaction.
239
+ #
240
+ # @return [Array] The minimal subset of public keys that should add signatures to the transaction.
241
+ def required_signatures
242
+ @database_api.get_required_signatures(trx: transaction) do |result|
243
+ result[:keys]
244
+ end
245
+ end
246
+
247
+ # @return [Boolean] True if the transaction has all of the required signatures.
248
+ def valid?
249
+ @database_api.verify_authority(trx: transaction) do |result|
250
+ result.valid
251
+ end
252
+ end
253
+ private
254
+ # See: https://github.com/steemit/steem/issues/1944
255
+ def canonical?(sig)
256
+ sig = sig.unpack('C*')
257
+
258
+ !(
259
+ ((sig[0] & 0x80 ) != 0) || ( sig[0] == 0 ) ||
260
+ ((sig[1] & 0x80 ) != 0) ||
261
+ ((sig[32] & 0x80 ) != 0) || ( sig[32] == 0 ) ||
262
+ ((sig[33] & 0x80 ) != 0)
263
+ )
264
+ end
265
+ end
266
+ end