ouroboros-peatio-bitgo 3.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,54 @@
1
+ require 'faraday'
2
+ require 'better-faraday'
3
+
4
+ module Peatio
5
+ module Bitgo
6
+ class Client
7
+ Error = Class.new(StandardError)
8
+ ConnectionError = Class.new(Error)
9
+
10
+ class ResponseError < Error
11
+ def initialize(msg)
12
+ super "#{msg}"
13
+ end
14
+ end
15
+
16
+ def initialize(endpoint, access_token)
17
+ @endpoint = URI.parse(endpoint)
18
+ @access_token = access_token
19
+ end
20
+
21
+ def rest_api(verb, path, data = nil)
22
+ args = [@endpoint.to_s + path]
23
+
24
+ if data
25
+ if %i[post put patch].include?(verb)
26
+ args << data.compact.to_json
27
+ args << { 'Content-Type' => 'application/json' }
28
+ else
29
+ args << data.compact
30
+ args << {}
31
+ end
32
+ else
33
+ args << nil
34
+ args << {}
35
+ end
36
+
37
+ args.last['Accept'] = 'application/json'
38
+ args.last['Authorization'] = 'Bearer ' + @access_token
39
+
40
+ response = Faraday.send(verb, *args)
41
+ response.assert_2xx!
42
+ response = JSON.parse(response.body)
43
+ response['error'].tap { |error| raise ResponseError.new(error) if error }
44
+ response
45
+ rescue Faraday::Error => e
46
+ if e.is_a?(Faraday::ConnectionFailed) || e.is_a?(Faraday::TimeoutError)
47
+ raise ConnectionError, e
48
+ else
49
+ raise ConnectionError, JSON.parse(e.response.body)['message']
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,42 @@
1
+ module Peatio
2
+ module Bitgo
3
+ module Hooks
4
+ BLOCKCHAIN_VERSION_REQUIREMENT = "~> 1.0.0"
5
+ WALLET_VERSION_REQUIREMENT = "~> 1.0.0"
6
+
7
+ class << self
8
+ def check_compatibility
9
+ unless Gem::Requirement.new(BLOCKCHAIN_VERSION_REQUIREMENT)
10
+ .satisfied_by?(Gem::Version.new(Peatio::Blockchain::VERSION))
11
+ [
12
+ "Bitgo blockchain version requirement was not satisfied by Peatio::Blockchain.",
13
+ "Bitgo blockchain requires #{BLOCKCHAIN_VERSION_REQUIREMENT}.",
14
+ "Peatio::Blockchain version is #{Peatio::Blockchain::VERSION}"
15
+ ].join('\n').tap { |s| Kernel.abort s }
16
+ end
17
+
18
+ unless Gem::Requirement.new(WALLET_VERSION_REQUIREMENT)
19
+ .satisfied_by?(Gem::Version.new(Peatio::Wallet::VERSION))
20
+ [
21
+ "Bitgo wallet version requirement was not satisfied by Peatio::Wallet.",
22
+ "Bitgo wallet requires #{WALLET_VERSION_REQUIREMENT}.",
23
+ "Peatio::Wallet version is #{Peatio::Wallet::VERSION}"
24
+ ].join('\n').tap { |s| Kernel.abort s }
25
+ end
26
+ end
27
+
28
+ def register
29
+ Peatio::Blockchain.registry[:bitgo] = Bitgo::Blockchain
30
+ Peatio::Wallet.registry[:bitgo] = Bitgo::Wallet
31
+ end
32
+ end
33
+
34
+ if defined?(Rails::Railtie)
35
+ require "peatio/bitgo/railtie"
36
+ else
37
+ check_compatibility
38
+ register
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,13 @@
1
+ module Peatio
2
+ module Bitgo
3
+ class Railtie < Rails::Railtie
4
+ config.before_initialize do
5
+ Hooks.check_compatibility
6
+ end
7
+
8
+ config.after_initialize do
9
+ Hooks.register
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,5 @@
1
+ module Peatio
2
+ module Bitgo
3
+ VERSION = "3.1.0".freeze
4
+ end
5
+ end
@@ -0,0 +1,345 @@
1
+ module Peatio
2
+ module Bitgo
3
+ class Wallet < Peatio::Wallet::Abstract
4
+ TIME_DIFFERENCE_IN_MINUTES = 10
5
+ XLM_MEMO_TYPES = { 'memoId': 'id', 'memoText': 'text', 'memoHash': 'hash', 'memoReturn': 'return' }
6
+
7
+ DEFAULT_FEATURES = { skip_deposit_collection: false }.freeze
8
+
9
+ def initialize(custom_features = {})
10
+ @features = DEFAULT_FEATURES.merge(custom_features).slice(*SUPPORTED_FEATURES)
11
+ @settings = {}
12
+ end
13
+
14
+ def configure(settings = {})
15
+ # Clean client state during configure.
16
+ @client = nil
17
+
18
+ @settings.merge!(settings.slice(*SUPPORTED_SETTINGS))
19
+
20
+ @wallet = @settings.fetch(:wallet) do
21
+ raise Peatio::Wallet::MissingSettingError, :wallet
22
+ end.slice(:uri, :address, :secret, :access_token, :wallet_id, :testnet)
23
+
24
+ @currency = @settings.fetch(:currency) do
25
+ raise Peatio::Wallet::MissingSettingError, :currency
26
+ end.slice(:id, :base_factor, :code, :options)
27
+ end
28
+
29
+ def create_address!(options = {})
30
+ currency = erc20_currency_id
31
+ options.deep_symbolize_keys!
32
+
33
+ if options.dig(:pa_details, :address_id).present? &&
34
+ options.dig(:pa_details, :updated_at).present? &&
35
+ time_difference_in_minutes(options.dig(:pa_details, :updated_at)) >= TIME_DIFFERENCE_IN_MINUTES
36
+
37
+ response = client.rest_api(:get, "#{currency}/wallet/#{wallet_id}/address/#{options.dig(:pa_details, :address_id)}")
38
+ { address: response['address'], secret: bitgo_wallet_passphrase }
39
+ elsif options.dig(:pa_details, :address_id).blank?
40
+ response = client.rest_api(:post, "#{currency}/wallet/#{wallet_id}/address")
41
+ { address: response['address'], secret: bitgo_wallet_passphrase, details: { address_id: response['id'] }}
42
+ end
43
+ rescue Bitgo::Client::Error => e
44
+ raise Peatio::Wallet::ClientError, e
45
+ end
46
+
47
+ def create_transaction!(transaction, options = {})
48
+ currency_options = @currency.fetch(:options).slice(:gas_limit, :gas_price, :erc20_contract_address)
49
+
50
+ if currency_options[:gas_limit].present? && currency_options[:gas_price].present?
51
+ options.merge!(currency_options)
52
+ create_eth_transaction(transaction, options)
53
+ else
54
+ amount = convert_to_base_unit(transaction.amount)
55
+
56
+ if options[:subtract_fee].to_s == 'true'
57
+ fee = build_raw_transaction(transaction)
58
+ baseFeeInfo = fee.dig('feeInfo','fee')
59
+ fee = baseFeeInfo.present? ? baseFeeInfo : fee.dig('txInfo','Fee')
60
+ amount -= fee.to_i
61
+ end
62
+
63
+ response = client.rest_api(:post, "#{currency_id}/wallet/#{wallet_id}/sendcoins", {
64
+ address: normalize_address(transaction.to_address.to_s),
65
+ amount: amount.to_s,
66
+ walletPassphrase: bitgo_wallet_passphrase,
67
+ memo: xlm_memo(transaction.to_address.to_s)
68
+ }.compact)
69
+
70
+ if response['feeString'].present?
71
+ fee = convert_from_base_unit(response['feeString'])
72
+ transaction.fee = fee
73
+ end
74
+
75
+ transaction.hash = normalize_txid(response['txid'])
76
+ transaction.fee_currency_id = erc20_currency_id
77
+ transaction
78
+ end
79
+ rescue Bitgo::Client::Error => e
80
+ raise Peatio::Wallet::ClientError, e
81
+ end
82
+
83
+
84
+ def build_raw_transaction(transaction)
85
+ client.rest_api(:post, "#{currency_id}/wallet/#{wallet_id}/tx/build", {
86
+ recipients: [{
87
+ address: transaction.to_address,
88
+ amount: convert_to_base_unit(transaction.amount).to_s
89
+ }]
90
+ }.compact)
91
+ end
92
+
93
+ def create_eth_transaction(transaction, options = {})
94
+ amount = convert_to_base_unit(transaction.amount)
95
+ hop = true unless options.slice(:gas_price).present?
96
+
97
+ fee_estimate = fee_estimate(amount.to_s, hop)
98
+
99
+ if transaction.options.present? && transaction.options[:gas_price].present?
100
+ options[:gas_price] = transaction.options[:gas_price]
101
+ else
102
+ options[:gas_price] = fee_estimate['minGasPrice'].to_i
103
+ end
104
+
105
+ response = client.rest_api(:post, "#{currency_id}/wallet/#{wallet_id}/sendcoins", {
106
+ address: transaction.to_address.to_s,
107
+ amount: amount.to_s,
108
+ walletPassphrase: bitgo_wallet_passphrase,
109
+ gas: options.fetch(:gas_limit).to_i,
110
+ gasPrice: options.fetch(:gas_price).to_i,
111
+ hop: hop
112
+ }.compact)
113
+
114
+ if response['feeString'].present?
115
+ fee = convert_from_base_unit(response['feeString'])
116
+ transaction.fee = fee
117
+ end
118
+
119
+ transaction.hash = normalize_txid(response['txid'])
120
+ transaction.fee_currency_id = erc20_currency_id
121
+ transaction.options = options
122
+ transaction
123
+ end
124
+
125
+ def fee_estimate(amount, hop)
126
+ client.rest_api(:get, "#{erc20_currency_id}/tx/fee", { amount: amount, hop: hop }.compact)
127
+ end
128
+
129
+ def load_balance!
130
+ if @currency.fetch(:options).slice(:erc20_contract_address).present?
131
+ load_erc20_balance!
132
+ else
133
+ response = client.rest_api(:get, "#{currency_id}/wallet/#{wallet_id}")
134
+ convert_from_base_unit(response.fetch('balanceString'))
135
+ end
136
+ rescue Bitgo::Client::Error => e
137
+ raise Peatio::Wallet::ClientError, e
138
+ end
139
+
140
+ def load_erc20_balance!
141
+ response = client.rest_api(:get, "#{erc20_currency_id}/wallet/#{wallet_id}?allTokens=true")
142
+ convert_from_base_unit(response.dig('tokens', currency_id, 'balanceString'))
143
+ rescue Bitgo::Client::Error => e
144
+ raise Peatio::Wallet::ClientError, e
145
+ end
146
+
147
+ def trigger_webhook_event(request)
148
+ # Retrieve testnet status
149
+ testnet_status = @wallet.fetch(:testnet)
150
+ # Determine if the 't' prefix should be applied
151
+ currency = (testnet_status == false || testnet_status.nil? || (testnet_status.is_a?(String) && testnet_status.downcase == "false")) ? @currency.fetch(:id) : 't' + @currency.fetch(:id)
152
+ if request.params['type'] == 'transfer'
153
+ return unless currency == request.params['coin'] &&
154
+ @wallet.fetch(:wallet_id) == request.params['wallet']
155
+ else
156
+ return unless @wallet.fetch(:wallet_id) == request.params['walletId']
157
+ end
158
+
159
+ if request.params['type'] == 'transfer'
160
+ transactions = fetch_transfer!(request.params['transfer'])
161
+ return transactions
162
+ elsif request.params['type'] == 'address_confirmation'
163
+ address_id = fetch_address_id(request.params['address'])
164
+ return { address_id: address_id, address: request.params['address'], currency_id: currency_id }
165
+ end
166
+ end
167
+
168
+ def register_webhooks!(url)
169
+ transfer_webhook(url)
170
+ address_confirmation_webhook(url)
171
+ end
172
+
173
+ def fetch_address_id(address)
174
+ currency = erc20_currency_id
175
+ client.rest_api(:get, "#{currency}/wallet/#{wallet_id}/address/#{address}")
176
+ .fetch('id')
177
+ rescue Bitgo::Client::Error => e
178
+ raise Peatio::Wallet::ClientError, e
179
+ end
180
+
181
+ def fetch_transfer!(id)
182
+ response = client.rest_api(:get, "#{currency_id}/wallet/#{wallet_id}/transfer/#{id}")
183
+ parse_entries(response['entries']).map do |entry|
184
+ to_address = if response.dig('coinSpecific', 'memo').present?
185
+ memo = response.dig('coinSpecific', 'memo')
186
+ memo_type = memo.kind_of?(Array) ? memo.first : memo
187
+ build_address(entry['address'], memo_type)
188
+ else
189
+ entry['address']
190
+ end
191
+ state = define_transaction_state(response['state'])
192
+
193
+ if response['outputs'].present?
194
+ output = response['outputs'].find { |out| out['address'] == to_address }
195
+ txout = output['index'] if output.present?
196
+ end
197
+
198
+ if response['feeString'].present?
199
+ fee = convert_from_base_unit(response['feeString']) / response['entries'].count
200
+ end
201
+
202
+ transaction = Peatio::Transaction.new(
203
+ currency_id: @currency.fetch(:id),
204
+ amount: convert_from_base_unit(entry['valueString']),
205
+ fee: fee,
206
+ fee_currency_id: erc20_currency_id,
207
+ hash: normalize_txid(response['txid']),
208
+ to_address: to_address,
209
+ block_number: response['height'],
210
+ txout: txout.to_i,
211
+ status: state
212
+ )
213
+
214
+ transaction if transaction.valid?
215
+ end.compact
216
+ rescue Bitgo::Client::Error => e
217
+ raise Peatio::Wallet::ClientError, e
218
+ end
219
+
220
+ def transfer_webhook(url)
221
+ client.rest_api(:post, "#{currency_id}/wallet/#{wallet_id}/webhooks", {
222
+ type: 'transfer',
223
+ allToken: true,
224
+ url: url,
225
+ label: "webhook for #{url}",
226
+ listenToFailureStates: false
227
+ })
228
+ end
229
+
230
+ def address_confirmation_webhook(url)
231
+ client.rest_api(:post, "#{currency_id}/wallet/#{wallet_id}/webhooks", {
232
+ type: 'address_confirmation',
233
+ allToken: true,
234
+ url: url,
235
+ label: "webhook for #{url}",
236
+ listenToFailureStates: false
237
+ })
238
+ end
239
+
240
+ def parse_entries(entries)
241
+ entries.map do |e|
242
+ e if e["valueString"].to_i.positive?
243
+ end.compact
244
+ end
245
+
246
+ private
247
+
248
+ def client
249
+ uri = @wallet.fetch(:uri) { raise Peatio::Wallet::MissingSettingError, :uri }
250
+ access_token = @wallet.fetch(:access_token) { raise Peatio::Wallet::MissingSettingError, :access_token }
251
+
252
+ currency_code_prefix = @wallet.fetch(:testnet) && @wallet.fetch(:testnet).to_s.downcase != "false" ? 't' : ''
253
+ uri = uri.gsub(/\/+\z/, '') + '/' + currency_code_prefix
254
+ @client ||= Client.new(uri, access_token)
255
+ end
256
+
257
+ def build_address(address, memo)
258
+ "#{address}?memoId=#{memo['value']}"
259
+ end
260
+
261
+ # All these functions will have to be done with the coin set to eth or teth
262
+ # since that is the actual coin type being used.
263
+ def erc20_currency_id
264
+ return 'eth' if @currency.fetch(:options).slice(:erc20_contract_address).present?
265
+
266
+ currency_id
267
+ end
268
+
269
+ def xlm_memo(address)
270
+ build_xlm_memo(address) if @currency.fetch(:id) == 'xlm'
271
+ end
272
+
273
+ def build_xlm_memo(address)
274
+ case address.split('?').last.split('=').first
275
+ when 'memoId'
276
+ memo_value_from(address, 'memoId')
277
+ when 'memoText'
278
+ memo_value_from(address, 'memoText')
279
+ when 'memoHash'
280
+ memo_value_from(address, 'memoHash')
281
+ when 'memoReturn'
282
+ memo_value_from(address, 'memoReturn')
283
+ end
284
+ end
285
+
286
+ def memo_value_from(address, type)
287
+ memo_value = address.partition(type + '=').last
288
+ return { type: XLM_MEMO_TYPES[type.to_sym], value: memo_value } if memo_value.present?
289
+ end
290
+
291
+ def currency_id
292
+ @currency.fetch(:id) { raise Peatio::Wallet::MissingSettingError, :id }
293
+ end
294
+
295
+ def bitgo_wallet_passphrase
296
+ @wallet.fetch(:secret)
297
+ end
298
+
299
+ def wallet_id
300
+ @wallet.fetch(:wallet_id)
301
+ end
302
+
303
+ def normalize_address(address)
304
+ if @currency.fetch(:id) == 'xlm'
305
+ address.split('?').first
306
+ else
307
+ address
308
+ end
309
+ end
310
+
311
+ def normalize_txid(txid)
312
+ txid.downcase
313
+ end
314
+
315
+ def convert_from_base_unit(value)
316
+ value.to_d / @currency.fetch(:base_factor)
317
+ end
318
+
319
+ def convert_to_base_unit(value)
320
+ x = value.to_d * @currency.fetch(:base_factor)
321
+ unless (x % 1).zero?
322
+ raise Peatio::WalletClient::Error,
323
+ "Failed to convert value to base (smallest) unit because it exceeds the maximum precision: " \
324
+ "#{value.to_d} - #{x.to_d} must be equal to zero."
325
+ end
326
+ x.to_i
327
+ end
328
+
329
+ def time_difference_in_minutes(updated_at)
330
+ (Time.now - updated_at)/60
331
+ end
332
+
333
+ def define_transaction_state(state)
334
+ case state
335
+ when 'unconfirmed'
336
+ 'pending'
337
+ when 'confirmed'
338
+ 'success'
339
+ when 'failed','rejected'
340
+ 'failed'
341
+ end
342
+ end
343
+ end
344
+ end
345
+ end
@@ -0,0 +1,16 @@
1
+ require "peatio"
2
+
3
+ module Peatio
4
+ module Bitgo
5
+ require "bigdecimal"
6
+ require "bigdecimal/util"
7
+
8
+ require "peatio/bitgo/blockchain"
9
+ require "peatio/bitgo/client"
10
+ require "peatio/bitgo/wallet"
11
+
12
+ require "peatio/bitgo/hooks"
13
+
14
+ require "peatio/bitgo/version"
15
+ end
16
+ end
@@ -0,0 +1,39 @@
1
+
2
+ lib = File.expand_path("../lib", __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require "peatio/bitgo/version"
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "ouroboros-peatio-bitgo"
8
+ spec.version = Peatio::Bitgo::VERSION
9
+ spec.authors = ["ISPOS"]
10
+ spec.email = ["contacto@ispos.mx"]
11
+
12
+ spec.summary = %q{Gem for extending Peatio plugable system with Bitgo implementation.}
13
+ spec.description = %q{Bitgo Peatio gem which implements Peatio::Blockchain::Abstract & Peatio::Wallet::Abstract.}
14
+ spec.homepage = "https://ispos.mx/"
15
+ spec.license = "MIT"
16
+
17
+ # Specify which files should be added to the gem when it is released.
18
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
19
+ spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
20
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
21
+ end
22
+ spec.bindir = "exe"
23
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
24
+ spec.require_paths = ["lib"]
25
+
26
+ spec.add_dependency "activesupport", "~> 6.1.0"
27
+ spec.add_dependency "better-faraday", "~> 2.0.1"
28
+ spec.add_dependency "faraday", "~> 1.10"
29
+ spec.add_dependency "memoist", "~> 0.16.0"
30
+ spec.add_dependency "peatio", ">= 3.1.1"
31
+ spec.add_dependency 'net-http-persistent', '~> 4.0.1'
32
+
33
+ spec.add_development_dependency "bundler", "~> 2.4.7"
34
+ spec.add_development_dependency "mocha", "~> 1.8"
35
+ spec.add_development_dependency "pry-byebug"
36
+ spec.add_development_dependency "rake", "~> 13.0"
37
+ spec.add_development_dependency "rspec", "~> 3.0"
38
+ spec.add_development_dependency "webmock", "~> 3.5"
39
+ end