chain-sdk 1.0.0.pre

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,187 @@
1
+ require 'json'
2
+ require 'net/http'
3
+ require 'net/https'
4
+ require 'openssl'
5
+ require 'thread'
6
+
7
+ require_relative './batch_response'
8
+ require_relative './errors'
9
+ require_relative './version'
10
+
11
+ module Chain
12
+ class Connection
13
+
14
+ # Parameters to the retry exponential backoff function.
15
+ MAX_RETRIES = 10
16
+ RETRY_BASE_DELAY_MS = 40
17
+ RETRY_MAX_DELAY_MS = 4000
18
+
19
+ NETWORK_ERRORS = [
20
+ InvalidRequestIDError,
21
+ SocketError,
22
+ EOFError,
23
+ IOError,
24
+ Timeout::Error,
25
+ Errno::ECONNABORTED,
26
+ Errno::ECONNRESET,
27
+ Errno::ETIMEDOUT,
28
+ Errno::EHOSTUNREACH,
29
+ Errno::ECONNREFUSED,
30
+ ]
31
+
32
+ def initialize(opts)
33
+ @opts = opts
34
+ @url = URI(@opts[:url])
35
+ @access_token = @opts[:access_token] || @url.userinfo
36
+ @http_mutex = Mutex.new
37
+ end
38
+
39
+ # Returns a copy of the configuration options
40
+ def opts
41
+ @opts.dup
42
+ end
43
+
44
+ def request(path, body = {})
45
+ _request_with_retries(path, body)[:body]
46
+ end
47
+
48
+ def batch_request(path, body = {}, &translate)
49
+ res = _request_with_retries(path, body)
50
+ body = res[:body]
51
+ response = res[:response]
52
+
53
+ successes = {}
54
+ errors = {}
55
+
56
+ body.each_with_index do |item, i|
57
+ if !!item['code']
58
+ errors[i] = APIError.new(item, response)
59
+ else
60
+ successes[i] = translate.call(item)
61
+ end
62
+ end
63
+
64
+ BatchResponse.new(
65
+ successes: successes,
66
+ errors: errors,
67
+ response: response,
68
+ )
69
+ end
70
+
71
+ def singleton_batch_request(path, body = {}, &translate)
72
+ batch = batch_request(path, body, &translate)
73
+
74
+ if batch.size != 1
75
+ raise "Invalid response, expected a single response object but got #{batch.items.size}"
76
+ end
77
+
78
+ raise batch.errors.values.first if batch.errors.size == 1
79
+
80
+ batch.successes.values.first
81
+ end
82
+
83
+ private
84
+
85
+ def _request_with_retries(path, body)
86
+ attempts = 0
87
+
88
+ begin
89
+ attempts += 1
90
+
91
+ # If this is a retry and not the first attempt, sleep before making the
92
+ # retry request.
93
+ sleep(backoff_delay(attempts)) if attempts > 1
94
+
95
+ _single_request(path, body)
96
+ rescue *NETWORK_ERRORS => e
97
+ raise e if attempts > MAX_RETRIES
98
+ retry
99
+ rescue APIError => e
100
+ raise e if attempts > MAX_RETRIES
101
+ retry if e.retriable?
102
+ raise e
103
+ end
104
+ end
105
+
106
+ def _single_request(path, body)
107
+ @http_mutex.synchronize do
108
+ # Timeout configuration
109
+ [:open_timeout, :read_timeout, :ssl_timeout].each do |k|
110
+ next unless @opts.key?(k)
111
+ http.send "#{k}=", @opts[k]
112
+ end
113
+
114
+ req = Net::HTTP::Post.new(@url.request_uri + path)
115
+ req['Accept'] = 'application/json'
116
+ req['Content-Type'] = 'application/json'
117
+ req['User-Agent'] = 'chain-sdk-ruby/' + Chain::VERSION
118
+ req.body = JSON.dump(body)
119
+
120
+ if @access_token
121
+ user, pass = @access_token.split(':')
122
+ req.basic_auth(user, pass)
123
+ end
124
+
125
+ response = http.request(req)
126
+
127
+ req_id = response['Chain-Request-ID']
128
+ unless req_id.is_a?(String) && req_id.size > 0
129
+ raise InvalidRequestIDError.new(response)
130
+ end
131
+
132
+ status = Integer(response.code)
133
+ parsed_body = nil
134
+
135
+ if status != 204 # No Content
136
+ begin
137
+ parsed_body = JSON.parse(response.body)
138
+ rescue JSON::JSONError
139
+ raise JSONError.new(req_id, response)
140
+ end
141
+ end
142
+
143
+ if status / 100 != 2
144
+ klass = status == 401 ? UnauthorizedError : APIError
145
+ raise klass.new(parsed_body, response)
146
+ end
147
+
148
+ {body: parsed_body, response: response}
149
+ end
150
+ end
151
+
152
+ private
153
+
154
+ MILLIS_TO_SEC = 0.001
155
+
156
+ def backoff_delay(attempt)
157
+ max = RETRY_BASE_DELAY_MS * 2**(attempt-1)
158
+ max = [max, RETRY_MAX_DELAY_MS].min
159
+ millis = rand(max) + 1
160
+ millis * MILLIS_TO_SEC
161
+ end
162
+
163
+ def http
164
+ return @http if @http
165
+
166
+ args = [@url.host, @url.port]
167
+
168
+ # Proxy configuration
169
+ if @opts.key?(:proxy_addr)
170
+ args += [@opts[:proxy_addr], @opts[:proxy_port]]
171
+ if @opts.key?(:proxy_user)
172
+ args += [@opts[:proxy_user], @opts[:proxy_pass]]
173
+ end
174
+ end
175
+
176
+ @http = Net::HTTP.new(*args)
177
+
178
+ @http.set_debug_output($stdout) if ENV['DEBUG']
179
+ if @url.scheme == 'https'
180
+ @http.use_ssl = true
181
+ @http.verify_mode = OpenSSL::SSL::VERIFY_PEER
182
+ end
183
+
184
+ @http
185
+ end
186
+ end
187
+ end
@@ -0,0 +1,4 @@
1
+ module Chain
2
+ DEFAULT_API_HOST = 'http://localhost:1999'
3
+ MAX_BLOCK_HEIGHT = (2 ** 63) - 1
4
+ end
@@ -0,0 +1,10 @@
1
+ require_relative './response_object'
2
+
3
+ module Chain
4
+ class ControlProgram < ResponseObject
5
+ # @!attribute [r] control_program
6
+ # Hex-encoded string representation of the control program.
7
+ # @return [String]
8
+ attrib :control_program
9
+ end
10
+ end
@@ -0,0 +1,80 @@
1
+ module Chain
2
+
3
+ # Base class for all errors raised by the Chain SDK.
4
+ class BaseError < StandardError; end
5
+
6
+ # InvalidRequestIDError arises when an HTTP response is received, but it does
7
+ # not contain headers that are included in all Chain API responses. This
8
+ # could arise due to a badly-configured proxy, or other upstream network
9
+ # issues.
10
+ class InvalidRequestIDError < BaseError
11
+ attr_accessor :response
12
+
13
+ def initialize(response)
14
+ super "Response HTTP header field Chain-Request-ID is unset. There may be network issues. Please check your local network settings."
15
+ self.response = response
16
+ end
17
+ end
18
+
19
+ # JSONError should be very rare, and will only arise if there is a bug in the
20
+ # Chain API, or if the upstream server is spoofing common Chain API response
21
+ # headers.
22
+ class JSONError < BaseError
23
+ attr_accessor :request_id
24
+ attr_accessor :response
25
+
26
+ def initialize(request_id, response)
27
+ super "Error decoding JSON response. Request-ID: #{request_id}"
28
+ self.request_id = request_id
29
+ self.response = response
30
+ end
31
+ end
32
+
33
+ # APIError describes errors that are codified by the Chain API. They have
34
+ # an error code, a message, and an optional detail field that provides
35
+ # additional context for the error.
36
+ class APIError < BaseError
37
+ RETRIABLE_STATUS_CODES = [
38
+ 408, # Request Timeout
39
+ 429, # Too Many Requests
40
+ 500, # Internal Server Error
41
+ 502, # Bad Gateway
42
+ 503, # Service Unavailable
43
+ 504, # Gateway Timeout
44
+ 509, # Bandwidth Limit Exceeded
45
+ ]
46
+
47
+ attr_accessor :code, :chain_message, :detail, :data, :temporary, :request_id, :response
48
+
49
+ def initialize(body, response)
50
+ self.code = body['code']
51
+ self.chain_message = body['message']
52
+ self.detail = body['detail']
53
+ self.temporary = body['temporary']
54
+
55
+ self.response = response
56
+ self.request_id = response['Chain-Request-ID'] if response
57
+
58
+ super self.class.format_error_message(code, chain_message, detail, request_id)
59
+ end
60
+
61
+ def retriable?
62
+ temporary || (response && RETRIABLE_STATUS_CODES.include?(Integer(response.code)))
63
+ end
64
+
65
+ def self.format_error_message(code, message, detail, request_id)
66
+ tokens = []
67
+ tokens << "Code: #{code}" if code.is_a?(String) && code.size > 0
68
+ tokens << "Message: #{message}"
69
+ tokens << "Detail: #{detail}" if detail.is_a?(String) && detail.size > 0
70
+ tokens << "Request-ID: #{request_id}"
71
+ tokens.join(' ')
72
+ end
73
+ end
74
+
75
+ # UnauthorizedError is a special case of APIError, and is raised when the
76
+ # response status code is 401. This is a common error case, so a discrete
77
+ # exception type is provided for convenience.
78
+ class UnauthorizedError < APIError; end
79
+
80
+ end
@@ -0,0 +1,91 @@
1
+ require_relative './connection'
2
+ require_relative './errors'
3
+ require_relative './mock_hsm'
4
+ require_relative './transaction'
5
+
6
+ module Chain
7
+ class HSMSigner
8
+
9
+ def initialize
10
+ @xpubs_by_signer = {}
11
+ end
12
+
13
+ def add_key(xpub_or_key, signer_conn)
14
+ xpub = xpub_or_key.is_a?(MockHSM::Key) ? xpub_or_key.xpub : xpub_or_key
15
+ @xpubs_by_signer[signer_conn] ||= []
16
+ @xpubs_by_signer[signer_conn] << xpub
17
+ @xpubs_by_signer[signer_conn].uniq!
18
+ end
19
+
20
+ def sign(tx_template)
21
+ return tx_template if @xpubs_by_signer.empty?
22
+
23
+ @xpubs_by_signer.each do |signer_conn, xpubs|
24
+ tx_template = signer_conn.singleton_batch_request(
25
+ '/sign-transaction',
26
+ transactions: [tx_template],
27
+ xpubs: xpubs,
28
+ ) { |item| Transaction::Template.new(item) }
29
+ end
30
+
31
+ tx_template
32
+ end
33
+
34
+ def sign_batch(tx_templates)
35
+ if @xpubs_by_signer.empty?
36
+ # Treat all templates as if signed successfully.
37
+ successes = tx_templates.each_with_index.reduce({}) do |memo, (t, i)|
38
+ memo[i] = t
39
+ memo
40
+ end
41
+ BatchResponse.new(successes: successes)
42
+ end
43
+
44
+ # We need to work towards a single, final BatchResponse that uses the
45
+ # original indexes. For the next cycle, we should retain only those
46
+ # templates for which the most recent sign response was successful, and
47
+ # maintain a mapping of each template's index in the upcoming request
48
+ # to its original index.
49
+
50
+ orig_index = (0...tx_templates.size).to_a
51
+ errors = {}
52
+
53
+ @xpubs_by_signer.each do |signer_conn, xpubs|
54
+ next_tx_templates = []
55
+ next_orig_index = []
56
+
57
+ batch = signer_conn.batch_request(
58
+ '/sign-transaction',
59
+ transactions: tx_templates,
60
+ xpubs: xpubs,
61
+ ) { |item| Transaction::Template.new(item) }
62
+
63
+ batch.successes.each do |i, template|
64
+ next_tx_templates << template
65
+ next_orig_index << orig_index[i]
66
+ end
67
+
68
+ batch.errors.each do |i, err|
69
+ errors[orig_index[i]] = err
70
+ end
71
+
72
+ tx_templates = next_tx_templates
73
+ orig_index = next_orig_index
74
+
75
+ # Early-exit if all templates have encountered an error.
76
+ break if tx_templates.empty?
77
+ end
78
+
79
+ successes = tx_templates.each_with_index.reduce({}) do |memo, (t, i)|
80
+ memo[orig_index[i]] = t
81
+ memo
82
+ end
83
+
84
+ BatchResponse.new(
85
+ successes: successes,
86
+ errors: errors,
87
+ )
88
+ end
89
+
90
+ end
91
+ end
@@ -0,0 +1,65 @@
1
+ require_relative './client_module'
2
+ require_relative './connection'
3
+ require_relative './query'
4
+ require_relative './response_object'
5
+
6
+ module Chain
7
+ class MockHSM
8
+
9
+ class ClientModule < Chain::ClientModule
10
+ # @return [Key::ClientModule]
11
+ def keys
12
+ @keys_module ||= Key::ClientModule.new(client)
13
+ end
14
+
15
+ # @return [Connection]
16
+ def signer_conn
17
+ return @signer_conn if @signer_conn
18
+
19
+ opts = client.conn.opts
20
+ opts[:url] += '/mockhsm'
21
+
22
+ @signer_conn = Connection.new(opts)
23
+ end
24
+ end
25
+
26
+ class Key < ResponseObject
27
+ # @!attribute [r] alias
28
+ # User specified, unique identifier of the key.
29
+ # @return [String]
30
+ attrib :alias
31
+
32
+ # @!attribute [r] xpub
33
+ # Hex-encoded string representation of the key.
34
+ # @return [String]
35
+ attrib :xpub
36
+
37
+ class ClientModule < Chain::ClientModule
38
+
39
+ # Creates a key object.
40
+ # @param [Hash] opts
41
+ # @return [Key]
42
+ def create(opts = {})
43
+ Key.new(client.conn.request('mockhsm/create-key', opts))
44
+ end
45
+
46
+ # @param [Hash] query
47
+ # @return [Query]
48
+ def query(query = {})
49
+ Query.new(client, query)
50
+ end
51
+ end
52
+
53
+ class Query < Chain::Query
54
+ def fetch(query)
55
+ client.conn.request('mockhsm/list-keys', query)
56
+ end
57
+
58
+ def translate(obj)
59
+ Key.new(obj)
60
+ end
61
+ end
62
+ end
63
+
64
+ end
65
+ end
@@ -0,0 +1,50 @@
1
+ module Chain
2
+ class Query
3
+ include ::Enumerable
4
+
5
+ # @return [Client]
6
+ attr_reader :client
7
+
8
+ def initialize(client, first_query)
9
+ @client = client
10
+ @first_query = first_query
11
+ end
12
+
13
+ # Iterate through objects in response, fetching the next page of results
14
+ # from the API as needed.
15
+ #
16
+ # Implements required method
17
+ # {https://ruby-doc.org/core/Enumerable.html Enumerable#each}.
18
+ # @return [void]
19
+ def each
20
+ page = fetch(@first_query)
21
+
22
+ loop do
23
+ if page['items'].empty? # we consume this array as we iterate
24
+ break if page['last_page']
25
+ page = fetch(page['next'])
26
+
27
+ # The second predicate (empty?) *should* be redundant, but we check it
28
+ # anyway as a defensive measure.
29
+ break if page['items'].empty?
30
+ end
31
+
32
+ item = page['items'].shift
33
+ yield translate(item)
34
+ end
35
+ end
36
+
37
+ # @abstract
38
+ def fetch(query)
39
+ raise NotImplementedError
40
+ end
41
+
42
+ # Overwrite to translate API response data to a different Ruby object.
43
+ # @abstract
44
+ def translate(response_object)
45
+ raise NotImplementedError
46
+ end
47
+
48
+ alias_method :all, :to_a
49
+ end
50
+ end
@@ -0,0 +1,81 @@
1
+ require 'json'
2
+ require 'time'
3
+
4
+ module Chain
5
+ class ResponseObject
6
+ def initialize(raw_attribs)
7
+ raw_attribs.each do |k, v|
8
+ next unless self.class.has_attrib?(k)
9
+ self[k] = self.class.translate(k, v) unless v.nil?
10
+ end
11
+ end
12
+
13
+ def to_h
14
+ self.class.attrib_opts.keys.reduce({}) do |memo, name|
15
+ memo[name] = instance_variable_get("@#{name}")
16
+ memo
17
+ end
18
+ end
19
+
20
+ def to_json(opts = nil)
21
+ to_h.to_json(opts)
22
+ end
23
+
24
+ def [](attrib_name)
25
+ attrib_name = attrib_name.to_sym
26
+ raise KeyError.new("key not found: #{attrib_name}") unless self.class.attrib_opts.key?(attrib_name)
27
+
28
+ instance_variable_get "@{attrib_name}"
29
+ end
30
+
31
+ def []=(attrib_name, value)
32
+ attrib_name = attrib_name.to_sym
33
+ raise KeyError.new("key not found: #{attrib_name}") unless self.class.attrib_opts.key?(attrib_name)
34
+
35
+ instance_variable_set "@#{attrib_name}", value
36
+ end
37
+
38
+ # @!visibility private
39
+ def self.attrib_opts
40
+ @attrib_opts ||= {}
41
+ end
42
+
43
+ # @!visibility private
44
+ def self.attrib(attrib_name, &translate)
45
+ attrib_opts[attrib_name.to_sym] = {translate: translate}
46
+ attr_accessor attrib_name
47
+ end
48
+
49
+ # @!visibility private
50
+ def self.has_attrib?(attrib_name)
51
+ attrib_opts.key?(attrib_name.to_sym)
52
+ end
53
+
54
+ # @!visibility private
55
+ def self.translate(attrib_name, raw_value)
56
+ attrib_name = attrib_name.to_sym
57
+ opts = attrib_opts[attrib_name]
58
+ return raw_value if opts[:translate].nil?
59
+
60
+ begin
61
+ opts[:translate].call raw_value
62
+ rescue => e
63
+ raise TranslateError.new(attrib_name, raw_value, e)
64
+ end
65
+ end
66
+
67
+ class TranslateError < StandardError
68
+ attr_reader :attrib_name
69
+ attr_reader :raw_value
70
+ attr_reader :source
71
+
72
+ def initialize(attrib_name, raw_value, source)
73
+ super "Translation error for attrib #{attrib_name}: #{source}"
74
+ @attrib_name = attrib_name
75
+ @raw_value = raw_value
76
+ @source = source
77
+ end
78
+ end
79
+
80
+ end
81
+ end