chain-sdk 1.0.0.pre

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,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