chain-sdk 1.0.0.pre
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/README.md +33 -0
- data/lib/chain/access_token.rb +66 -0
- data/lib/chain/account.rb +101 -0
- data/lib/chain/asset.rb +103 -0
- data/lib/chain/balance.rb +36 -0
- data/lib/chain/batch_response.rb +21 -0
- data/lib/chain/client.rb +75 -0
- data/lib/chain/client_module.rb +11 -0
- data/lib/chain/config.rb +121 -0
- data/lib/chain/connection.rb +187 -0
- data/lib/chain/constants.rb +4 -0
- data/lib/chain/control_program.rb +10 -0
- data/lib/chain/errors.rb +80 -0
- data/lib/chain/hsm_signer.rb +91 -0
- data/lib/chain/mock_hsm.rb +65 -0
- data/lib/chain/query.rb +50 -0
- data/lib/chain/response_object.rb +81 -0
- data/lib/chain/transaction.rb +472 -0
- data/lib/chain/transaction_feed.rb +100 -0
- data/lib/chain/unspent_output.rb +90 -0
- data/lib/chain/version.rb +3 -0
- data/lib/chain.rb +3 -0
- metadata +119 -0
@@ -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
|
data/lib/chain/errors.rb
ADDED
@@ -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
|
data/lib/chain/query.rb
ADDED
@@ -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
|