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