straight 0.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,182 @@
1
+ module Straight
2
+
3
+ # This module should be included into your own class to extend it with Gateway functionality.
4
+ # For example, if you have a ActiveRecord model called Gateway, you can include GatewayModule into it
5
+ # and you'll now be able to do everything Straight::Gateway can do, but you'll also get AR Database storage
6
+ # funcionality, its validations etc.
7
+ #
8
+ # The right way to implement this would be to do it the other way: inherit from Straight::Gateway, then
9
+ # include ActiveRecord, but at this point ActiveRecord doesn't work this way. Furthermore, some other libraries, like Sequel,
10
+ # also require you to inherit from them. Thus, the module.
11
+ #
12
+ # When this module is included, it doesn't actually *include* all the methods, some are prepended (see Ruby docs on #prepend).
13
+ # It is important specifically for getters and setters and as a general rule only getters and setters are prepended.
14
+ #
15
+ # If you don't want to bother yourself with modules, please use Straight::Gateway class and simply create new instances of it.
16
+ # However, if you are contributing to the library, all new funcionality should go to either Straight::GatewayModule::Includable or
17
+ # Straight::GatewayModule::Prependable (most likely the former).
18
+ module GatewayModule
19
+
20
+ # Only add getters and setters for those properties in the extended class
21
+ # that don't already have them. This is very useful with ActiveRecord for example
22
+ # where we don't want to override AR getters and setters that set attributes.
23
+ def self.included(base)
24
+ base.class_eval do
25
+ [
26
+ :pubkey,
27
+ :confirmations_required,
28
+ :status_check_schedule,
29
+ :blockchain_adapters,
30
+ :exchange_rate_adapters,
31
+ :order_callbacks,
32
+ :order_class,
33
+ :default_currency,
34
+ :name
35
+ ].each do |field|
36
+ attr_reader field unless base.method_defined?(field)
37
+ attr_writer field unless base.method_defined?("#{field}=")
38
+ prepend Prependable
39
+ include Includable
40
+ end
41
+ end
42
+ end
43
+
44
+ # Determines the algorithm for consequitive checks of the order status.
45
+ DEFAULT_STATUS_CHECK_SCHEDULE = -> (period, iteration_index) do
46
+ return false if period > 640
47
+ iteration_index += 1
48
+ if iteration_index > 5
49
+ period *= 2
50
+ iteration_index = 0
51
+ end
52
+ return { period: period, iteration_index: iteration_index }
53
+ end
54
+
55
+ # If you are defining methods in this module, it means you most likely want to
56
+ # call super() somehwere inside those methods.
57
+ #
58
+ # In short, the idea is to let the class we're being prepended to do its magic
59
+ # after out methods are finished.
60
+ module Prependable
61
+ end
62
+
63
+ module Includable
64
+
65
+ # Creates a new order for the address derived from the pubkey and the keychain_id argument provided.
66
+ # See explanation of this keychain_id argument is in the description for the #address_for_keychain_id method.
67
+ def order_for_keychain_id(amount:, keychain_id:, currency: nil, btc_denomination: :satoshi)
68
+
69
+ amount = amount_from_exchange_rate(
70
+ amount,
71
+ currency: currency,
72
+ btc_denomination: btc_denomination
73
+ )
74
+
75
+ order = Kernel.const_get(order_class).new
76
+ order.amount = amount
77
+ order.gateway = self
78
+ order.address = address_for_keychain_id(keychain_id)
79
+ order.keychain_id = keychain_id
80
+ order
81
+ end
82
+
83
+ # Returns a Base58-encoded Bitcoin address to which the payment transaction
84
+ # is expected to arrive. id is an an integer > 0 (hopefully not too large and hopefully
85
+ # the one a user of this class is going to properly increment) that is used to generate a
86
+ # an BIP32 bitcoin address deterministically.
87
+ def address_for_keychain_id(id)
88
+ keychain.node_for_path(id.to_s).to_address
89
+ end
90
+
91
+ def fetch_transaction(tid, address: nil)
92
+ try_adapters(@blockchain_adapters) { |b| b.fetch_transaction(tid, address: address) }
93
+ end
94
+
95
+ def fetch_transactions_for(address)
96
+ try_adapters(@blockchain_adapters) { |b| b.fetch_transactions_for(address) }
97
+ end
98
+
99
+ def fetch_balance_for(address)
100
+ try_adapters(@blockchain_adapters) { |b| b.fetch_balance_for(address) }
101
+ end
102
+
103
+ def keychain
104
+ @keychain ||= MoneyTree::Node.from_serialized_address(@pubkey)
105
+ end
106
+
107
+ # This is a callback method called from each order
108
+ # whenever an order status changes.
109
+ def order_status_changed(order)
110
+ @order_callbacks.each do |c|
111
+ c.call(order)
112
+ end
113
+ end
114
+
115
+ # Gets exchange rates from one of the exchange rate adapters,
116
+ # then calculates how much BTC does the amount in the given currency represents.
117
+ #
118
+ # You can also feed this method various bitcoin denominations.
119
+ # It will always return amount in Satoshis.
120
+ def amount_from_exchange_rate(amount, currency:, btc_denomination: :satoshi)
121
+ currency = self.default_currency if currency.nil?
122
+ btc_denomination = :satoshi if btc_denomination.nil?
123
+ currency = currency.to_s.upcase
124
+ if currency == 'BTC'
125
+ return Satoshi.new(amount, from_unit: btc_denomination).to_i
126
+ end
127
+
128
+ try_adapters(@exchange_rate_adapters) do |a|
129
+ a.convert_from_currency(amount, currency: currency)
130
+ end
131
+ end
132
+
133
+ private
134
+
135
+ # Calls the block with each adapter until one of them does not fail.
136
+ # Fails with the last exception.
137
+ def try_adapters(adapters, &block)
138
+ last_exception = nil
139
+ adapters.each do |adapter|
140
+ begin
141
+ result = yield(adapter)
142
+ last_exception = nil
143
+ return result
144
+ rescue Exception => e
145
+ last_exception = e
146
+ # If an Exception is raised, it passes on
147
+ # to the next adapter and attempts to call a method on it.
148
+ end
149
+ end
150
+ raise last_exception if last_exception
151
+ end
152
+
153
+ end
154
+
155
+ end
156
+
157
+
158
+ class Gateway
159
+
160
+ include GatewayModule
161
+
162
+ def initialize
163
+ @default_currency = 'BTC'
164
+ @blockchain_adapters = [
165
+ Blockchain::BlockchainInfoAdapter.mainnet_adapter,
166
+ Blockchain::HelloblockIoAdapter.mainnet_adapter
167
+ ]
168
+ @exchange_rate_adapters = [
169
+ ExchangeRate::BitpayAdapter.new,
170
+ ExchangeRate::CoinbaseAdapter.new,
171
+ ExchangeRate::BitstampAdapter.new
172
+ ]
173
+ @status_check_schedule = DEFAULT_STATUS_CHECK_SCHEDULE
174
+ end
175
+
176
+ def order_class
177
+ "Straight::Order"
178
+ end
179
+
180
+ end
181
+
182
+ end
@@ -0,0 +1,195 @@
1
+ module Straight
2
+
3
+ # This module should be included into your own class to extend it with Order functionality.
4
+ # For example, if you have a ActiveRecord model called Order, you can include OrderModule into it
5
+ # and you'll now be able to do everything to check order's status, but you'll also get AR Database storage
6
+ # funcionality, its validations etc.
7
+ #
8
+ # The right way to implement this would be to do it the other way: inherit from Straight::Order, then
9
+ # include ActiveRecord, but at this point ActiveRecord doesn't work this way. Furthermore, some other libraries, like Sequel,
10
+ # also require you to inherit from them. Thus, the module.
11
+ #
12
+ # When this module is included, it doesn't actually *include* all the methods, some are prepended (see Ruby docs on #prepend).
13
+ # It is important specifically for getters and setters and as a general rule only getters and setters are prepended.
14
+ #
15
+ # If you don't want to bother yourself with modules, please use Straight::Order class and simply create new instances of it.
16
+ # However, if you are contributing to the library, all new funcionality should go to either Straight::OrderModule::Includable or
17
+ # Straight::OrderModule::Prependable (most likely the former).
18
+ module OrderModule
19
+
20
+ # Only add getters and setters for those properties in the extended class
21
+ # that don't already have them. This is very useful with ActiveRecord for example
22
+ # where we don't want to override AR getters and setters that set attribtues.
23
+ def self.included(base)
24
+ base.class_eval do
25
+ [:amount, :address, :gateway, :keychain_id, :status, :tid].each do |field|
26
+ attr_reader field unless base.method_defined?(field)
27
+ attr_writer field unless base.method_defined?("#{field}=")
28
+ end
29
+ prepend Prependable
30
+ include Includable
31
+ end
32
+ end
33
+
34
+ # Worth noting that statuses above 1 are immutable. That is, an order status cannot be changed
35
+ # if it is more than 1. It makes sense because if an order is paid (5) or expired (2), nothing
36
+ # else should be able to change the status back. Similarly, if an order is overpaid (4) or
37
+ # underpaid (5), it requires admin supervision and possibly a new order to be created.
38
+ STATUSES = {
39
+ new: 0, # no transactions received
40
+ unconfirmed: 1, # transaction has been received doesn't have enough confirmations yet
41
+ paid: 2, # transaction received with enough confirmations and the correct amount
42
+ underpaid: 3, # amount that was received in a transaction was not enough
43
+ overpaid: 4, # amount that was received in a transaction was too large
44
+ expired: 5 # too much time passed since creating an order
45
+ }
46
+
47
+ class IncorrectAmount < Exception; end
48
+
49
+ # If you are defining methods in this module, it means you most likely want to
50
+ # call super() somehwere inside those methods. An example would be the #status=
51
+ # setter. We do our thing, then call super() so that the class this module is prepended to
52
+ # could do its thing. For instance, if we included it into ActiveRecord, then after
53
+ # #status= is executed, it would call ActiveRecord model setter #status=
54
+ #
55
+ # In short, the idea is to let the class we're being prepended to do its magic
56
+ # after out methods are finished.
57
+ module Prependable
58
+
59
+ # Checks #transaction and returns one of the STATUSES based
60
+ # on the meaning of each status and the contents of transaction
61
+ # If as_sym is set to true, then each status is returned as Symbol, otherwise
62
+ # an equivalent Integer from STATUSES is returned.
63
+ def status(as_sym: false, reload: false)
64
+ @status = super() if defined?(super)
65
+ # Prohibit status update if the order was paid in some way.
66
+ # This is just a caching workaround so we don't query
67
+ # the blockchain needlessly. The actual safety switch is in the setter.
68
+ # Therefore, even if you remove the following line, status won't actually
69
+ # be allowed to change.
70
+ return @status if @status && @status > 1
71
+
72
+ if reload || !@status
73
+ t = transaction(reload: reload)
74
+ self.status = if t.nil?
75
+ STATUSES[:new]
76
+ else
77
+ if t[:confirmations] >= gateway.confirmations_required
78
+ if t[:total_amount] == amount
79
+ STATUSES[:paid]
80
+ elsif t[:total_amount] < amount
81
+ STATUSES[:underpaid]
82
+ else
83
+ STATUSES[:overpaid]
84
+ end
85
+ else
86
+ STATUSES[:unconfirmed]
87
+ end
88
+ end
89
+ end
90
+ as_sym ? STATUSES.invert[@status] : @status
91
+ end
92
+
93
+ def status=(new_status)
94
+ # Prohibit status update if the order was paid in some way,
95
+ # so statuses above 1 are in fact immutable.
96
+ return false if @status && @status > 1
97
+
98
+ self.tid = transaction[:tid] if transaction
99
+
100
+ # Pay special attention to the order of these statements. If you place
101
+ # the assignment @status = new_status below the callback call,
102
+ # you may get a "Stack level too deep" error if the callback checks
103
+ # for the status and it's nil (therefore, force reload and the cycle continues).
104
+ #
105
+ # The order in which these statements currently are prevents that error, because
106
+ # by the time a callback checks the status it's already set.
107
+ @status_changed = (@status != new_status)
108
+ @status = new_status
109
+ gateway.order_status_changed(self) if @status_changed
110
+ super if defined?(super)
111
+ end
112
+
113
+ end
114
+
115
+ module Includable
116
+
117
+ # Returns an array of transactions for the order's address, each as a hash:
118
+ # [ {tid: "feba9e7bfea...", amount: 1202000, ...} ]
119
+ #
120
+ # An order is supposed to have only one transaction to its address, but we cannot
121
+ # always guarantee that (especially when a merchant decides to reuse the address
122
+ # for some reason -- he shouldn't but you know people).
123
+ #
124
+ # Therefore, this method returns all of the transactions.
125
+ # For compliance, there's also a #transaction method which always returns
126
+ # the last transaction made to the address.
127
+ def transactions(reload: false)
128
+ @transactions = gateway.fetch_transactions_for(address) if reload || !@transactions
129
+ @transactions
130
+ end
131
+
132
+ # Last transaction made to the address. Always use this method to check whether a transaction
133
+ # for this order has arrived. We pick last and not first because an address may be reused and we
134
+ # always assume it's the last transaction that we want to check.
135
+ def transaction(reload: false)
136
+ transactions(reload: reload).first
137
+ end
138
+
139
+ # Starts a loop which calls #status(reload: true) according to the schedule
140
+ # determined in @status_check_schedule. This method is supposed to be
141
+ # called in a separate thread, for example:
142
+ #
143
+ # Thread.new do
144
+ # order.start_periodic_status_check
145
+ # end
146
+ #
147
+ def start_periodic_status_check
148
+ check_status_on_schedule
149
+ end
150
+
151
+ # Recursion here! Keeps calling itself according to the schedule until
152
+ # either the status changes or the schedule tells it to stop.
153
+ def check_status_on_schedule(period: 10, iteration_index: 0)
154
+ self.status(reload: true)
155
+ schedule = gateway.status_check_schedule.call(period, iteration_index)
156
+ if schedule && self.status < 2 # Stop checking if status is >= 2
157
+ sleep period
158
+ check_status_on_schedule(
159
+ period: schedule[:period],
160
+ iteration_index: schedule[:iteration_index]
161
+ )
162
+ else
163
+ self.status = STATUSES[:expired]
164
+ end
165
+ end
166
+
167
+ def to_json
168
+ to_h.to_json
169
+ end
170
+
171
+ def to_h
172
+ { status: status, amount: amount, address: address, tid: tid }
173
+ end
174
+
175
+ end
176
+
177
+ end
178
+
179
+ # Instances of this class are generated when we'd like to start watching
180
+ # some addresses to check whether a transaction containing a certain amount
181
+ # has arrived to it.
182
+ #
183
+ # It is worth noting that instances do not know how store themselves anywhere,
184
+ # so as the class is written here, those instances are only supposed to exist
185
+ # in memory. Storing orders is entirely up to you.
186
+ class Order
187
+ include OrderModule
188
+
189
+ def initialize
190
+ @status = 0
191
+ end
192
+
193
+ end
194
+
195
+ end
data/lib/straight.rb ADDED
@@ -0,0 +1,18 @@
1
+ require 'money-tree'
2
+ require 'satoshi-unit'
3
+ require 'json'
4
+ require 'uri'
5
+ require 'open-uri'
6
+ require 'yaml'
7
+
8
+ require_relative 'straight/blockchain_adapter'
9
+ require_relative 'straight/blockchain_adapters/blockchain_info_adapter'
10
+ require_relative 'straight/blockchain_adapters/helloblock_io_adapter'
11
+
12
+ require_relative 'straight/exchange_rate_adapter'
13
+ require_relative 'straight/exchange_rate_adapters/bitpay_adapter'
14
+ require_relative 'straight/exchange_rate_adapters/coinbase_adapter'
15
+ require_relative 'straight/exchange_rate_adapters/bitstamp_adapter'
16
+
17
+ require_relative 'straight/order'
18
+ require_relative 'straight/gateway'
@@ -0,0 +1,52 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe Straight::Blockchain::BlockchainInfoAdapter do
4
+
5
+ subject(:adapter) { Straight::Blockchain::BlockchainInfoAdapter.mainnet_adapter }
6
+
7
+ it "fetches all transactions for the current address" do
8
+ address = "3B1QZ8FpAaHBgkSB5gFt76ag5AW9VeP8xp"
9
+ expect(adapter).to receive(:straighten_transaction).with(anything, address: address).at_least(:once)
10
+ expect(adapter.fetch_transactions_for(address)).not_to be_empty
11
+ end
12
+
13
+ it "fetches the balance for a given address" do
14
+ address = "3B1QZ8FpAaHBgkSB5gFt76ag5AW9VeP8xp"
15
+ expect(adapter.fetch_balance_for(address)).to be_kind_of(Integer)
16
+ end
17
+
18
+ it "fetches a single transaction" do
19
+ tid = 'ae0d040f48d75fdc46d9035236a1782164857d6f0cca1f864640281115898560'
20
+ expect(adapter.fetch_transaction(tid)[:total_amount]).to eq(832947)
21
+ end
22
+
23
+ it "calculates the number of confirmations for each transaction" do
24
+ tid = 'ae0d040f48d75fdc46d9035236a1782164857d6f0cca1f864640281115898560'
25
+ expect(adapter.fetch_transaction(tid)[:confirmations]).to be > 0
26
+ end
27
+
28
+ it "gets a transaction id among other data" do
29
+ tid = 'ae0d040f48d75fdc46d9035236a1782164857d6f0cca1f864640281115898560'
30
+ expect(adapter.fetch_transaction(tid)[:tid]).to eq(tid)
31
+ end
32
+
33
+ it "caches blockchain.info latestblock requests" do
34
+ expect(adapter).to receive(:http_request).once.and_return('{ "height": 1 }')
35
+ adapter.send(:calculate_confirmations, { "block_height" => 1 }, force_latest_block_reload: true)
36
+ adapter.send(:calculate_confirmations, { "block_height" => 1 })
37
+ adapter.send(:calculate_confirmations, { "block_height" => 1 })
38
+ adapter.send(:calculate_confirmations, { "block_height" => 1 })
39
+ adapter.send(:calculate_confirmations, { "block_height" => 1 })
40
+ end
41
+
42
+ it "raises an exception when something goes wrong with fetching datd" do
43
+ allow_any_instance_of(URI).to receive(:read).and_raise(OpenURI::HTTPError)
44
+ expect( -> { adapter.http_request("http://blockchain.info/a-timed-out-request") }).to raise_error(Straight::Blockchain::Adapter::RequestError)
45
+ end
46
+
47
+ it "calculates total_amount of a transaction for the given address only" do
48
+ t = { 'out' => [{ 'value' => 1, 'addr' => 'address1'}, { 'value' => 1, 'addr' => 'address2'}] }
49
+ expect(adapter.send(:straighten_transaction, t, address: 'address1')[:total_amount]).to eq(1)
50
+ end
51
+
52
+ end
@@ -0,0 +1,43 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe Straight::Blockchain::HelloblockIoAdapter do
4
+
5
+ subject(:adapter) { Straight::Blockchain::HelloblockIoAdapter.mainnet_adapter }
6
+
7
+ it "fetches all transactions for the current address" do
8
+ address = "3B1QZ8FpAaHBgkSB5gFt76ag5AW9VeP8xp"
9
+ expect(adapter).to receive(:straighten_transaction).with(anything, address: address).at_least(:once)
10
+ expect(adapter.fetch_transactions_for(address)).not_to be_empty
11
+ end
12
+
13
+ it "fetches the balance for a given address" do
14
+ address = "3B1QZ8FpAaHBgkSB5gFt76ag5AW9VeP8xp"
15
+ expect(adapter.fetch_balance_for(address)).to be_kind_of(Integer)
16
+ end
17
+
18
+ it "fetches a single transaction" do
19
+ tid = 'ae0d040f48d75fdc46d9035236a1782164857d6f0cca1f864640281115898560'
20
+ expect(adapter.fetch_transaction(tid)[:total_amount]).to eq(832947)
21
+ end
22
+
23
+ it "gets a transaction id among other data" do
24
+ tid = 'ae0d040f48d75fdc46d9035236a1782164857d6f0cca1f864640281115898560'
25
+ expect(adapter.fetch_transaction(tid)[:tid]).to eq(tid)
26
+ end
27
+
28
+ it "returns the number of confirmations for a transaction" do
29
+ tid = 'ae0d040f48d75fdc46d9035236a1782164857d6f0cca1f864640281115898560'
30
+ expect(adapter.fetch_transaction(tid)[:confirmations]).to be > 0
31
+ end
32
+
33
+ it "raises an exception when something goes wrong with fetching datd" do
34
+ allow_any_instance_of(URI).to receive(:read).and_raise(OpenURI::HTTPError)
35
+ expect( -> { adapter.http_request("http://blockchain.info/a-timed-out-request") }).to raise_error(Straight::Blockchain::Adapter::RequestError)
36
+ end
37
+
38
+ it "calculates total_amount of a transaction for the given address only" do
39
+ t = { 'outputs' => [{ 'value' => 1, 'address' => 'address1'}, { 'value' => 1, 'address' => 'address2'}] }
40
+ expect(adapter.send(:straighten_transaction, t, address: 'address1')[:total_amount]).to eq(1)
41
+ end
42
+
43
+ end
@@ -0,0 +1,50 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe Straight::ExchangeRate::Adapter do
4
+
5
+ class Straight::ExchangeRate::Adapter
6
+ FETCH_URL = ''
7
+ end
8
+
9
+ before(:each) do
10
+ @exchange_adapter = Straight::ExchangeRate::Adapter.new
11
+ end
12
+
13
+ describe "converting currencies" do
14
+
15
+ before(:each) do
16
+ allow(@exchange_adapter).to receive(:fetch_rates!)
17
+ allow(@exchange_adapter).to receive(:rate_for).with('USD').and_return(450.5412)
18
+ end
19
+
20
+ it "converts amount from currency into BTC" do
21
+ expect(@exchange_adapter.convert_from_currency(2252.706, currency: 'USD')).to eq(500000000)
22
+ end
23
+
24
+ it "converts from btc into currency" do
25
+ expect(@exchange_adapter.convert_to_currency(500000000, currency: 'USD')).to eq(2252.706)
26
+ end
27
+
28
+ it "shows btc amounts in various denominations" do
29
+ expect(@exchange_adapter.convert_from_currency(2252.706, currency: 'USD', btc_denomination: :btc)).to eq(5)
30
+ expect(@exchange_adapter.convert_to_currency(5, currency: 'USD', btc_denomination: :btc)).to eq(2252.706)
31
+ end
32
+
33
+ it "accepts string as amount and converts it properly" do
34
+ expect(@exchange_adapter.convert_from_currency('2252.706', currency: 'USD', btc_denomination: :btc)).to eq(5)
35
+ expect(@exchange_adapter.convert_to_currency('5', currency: 'USD', btc_denomination: :btc)).to eq(2252.706)
36
+ end
37
+
38
+ end
39
+
40
+ it "when checking for rates, only calls fetch_rates! if they were checked long time ago or never" do
41
+ uri_mock = double('uri mock')
42
+ expect(URI).to receive(:parse).and_return(uri_mock).twice
43
+ expect(uri_mock).to receive(:read).and_return('{ "USD": 534.4343 }').twice
44
+ @exchange_adapter.rate_for('USD')
45
+ @exchange_adapter.rate_for('USD') # not calling fetch_rates! because we've just checked
46
+ @exchange_adapter.instance_variable_set(:@rates_updated_at, Time.now-1900)
47
+ @exchange_adapter.rate_for('USD')
48
+ end
49
+
50
+ end
@@ -0,0 +1,14 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe Straight::ExchangeRate::BitpayAdapter do
4
+
5
+ before(:each) do
6
+ @exchange_adapter = Straight::ExchangeRate::BitpayAdapter.new
7
+ end
8
+
9
+ it "finds the rate for currency code" do
10
+ expect(@exchange_adapter.rate_for('USD')).to be_kind_of(Float)
11
+ expect( -> { @exchange_adapter.rate_for('FEDcoin') }).to raise_error(Straight::ExchangeRate::Adapter::CurrencyNotSupported)
12
+ end
13
+
14
+ end
@@ -0,0 +1,14 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe Straight::ExchangeRate::BitstampAdapter do
4
+
5
+ before(:each) do
6
+ @exchange_adapter = Straight::ExchangeRate::BitstampAdapter.new
7
+ end
8
+
9
+ it "finds the rate for currency code" do
10
+ expect(@exchange_adapter.rate_for('USD')).to be_kind_of(Float)
11
+ expect( -> { @exchange_adapter.rate_for('FEDcoin') }).to raise_error(Straight::ExchangeRate::Adapter::CurrencyNotSupported)
12
+ end
13
+
14
+ end
@@ -0,0 +1,14 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe Straight::ExchangeRate::CoinbaseAdapter do
4
+
5
+ before(:each) do
6
+ @exchange_adapter = Straight::ExchangeRate::CoinbaseAdapter.new
7
+ end
8
+
9
+ it "finds the rate for currency code" do
10
+ expect(@exchange_adapter.rate_for('USD')).to be_kind_of(Float)
11
+ expect( -> { @exchange_adapter.rate_for('FEDcoin') }).to raise_error(Straight::ExchangeRate::Adapter::CurrencyNotSupported)
12
+ end
13
+
14
+ end
@@ -0,0 +1,75 @@
1
+ require 'spec_helper'
2
+
3
+ RSpec.describe Straight::Gateway do
4
+
5
+ before(:each) do
6
+ @mock_adapter = double("mock blockchain adapter")
7
+ @gateway = Straight::Gateway.new
8
+ @gateway.pubkey = "pubkey"
9
+ @gateway.order_class = "Straight::Order"
10
+ @gateway.blockchain_adapters = [@mock_adapter]
11
+ @gateway.status_check_schedule = Straight::Gateway::DEFAULT_STATUS_CHECK_SCHEDULE
12
+ @gateway.order_callbacks = []
13
+ end
14
+
15
+ it "passes methods on to the available adapter" do
16
+ @gateway.instance_variable_set('@blockchain_adapters', [@mock_adapter])
17
+ expect(@mock_adapter).to receive(:fetch_transaction).once
18
+ @gateway.fetch_transaction("xxx")
19
+ end
20
+
21
+ it "uses the next availabale adapter when something goes wrong with the current one" do
22
+ another_mock_adapter = double("another_mock blockchain adapter")
23
+ @gateway.instance_variable_set('@blockchain_adapters', [@mock_adapter, another_mock_adapter])
24
+ allow(@mock_adapter).to receive(:fetch_transaction).once.and_raise(Exception)
25
+ expect(another_mock_adapter).to receive(:fetch_transaction).once
26
+ @gateway.fetch_transaction("xxx")
27
+ end
28
+
29
+ it "creates new orders and addresses for them" do
30
+ @gateway.pubkey = MoneyTree::Master.new.to_serialized_address
31
+ expected_address = MoneyTree::Node.from_serialized_address(@gateway.pubkey).node_for_path("1").to_address
32
+ expect(@gateway.order_for_keychain_id(amount: 1, keychain_id: 1).address).to eq(expected_address)
33
+ end
34
+
35
+ it "calls all the order callbacks" do
36
+ callback1 = double('callback1')
37
+ callback2 = double('callback1')
38
+ @gateway.pubkey = MoneyTree::Master.new.to_serialized_address
39
+ @gateway.order_callbacks = [callback1, callback2]
40
+
41
+ order = @gateway.order_for_keychain_id(amount: 1, keychain_id: 1)
42
+ expect(callback1).to receive(:call).with(order)
43
+ expect(callback2).to receive(:call).with(order)
44
+ @gateway.order_status_changed(order)
45
+ end
46
+
47
+ describe "exchange rate calculation" do
48
+
49
+ it "sets order amount in satoshis calculated from another currency" do
50
+ adapter = Straight::ExchangeRate::BitpayAdapter.new
51
+ allow(adapter).to receive(:rate_for).and_return(450.5412)
52
+ @gateway.exchange_rate_adapters = [adapter]
53
+ expect(@gateway.amount_from_exchange_rate(2252.706, currency: 'USD')).to eq(500000000)
54
+ end
55
+
56
+ it "tries various exchange adapters until one of them actually returns an exchange rate" do
57
+ adapter1 = Straight::ExchangeRate::BitpayAdapter.new
58
+ adapter2 = Straight::ExchangeRate::BitpayAdapter.new
59
+ allow(adapter1).to receive(:rate_for).and_return( -> { raise "connection problem" })
60
+ allow(adapter2).to receive(:rate_for).and_return(450.5412)
61
+ @gateway.exchange_rate_adapters = [adapter1, adapter2]
62
+ expect(@gateway.amount_from_exchange_rate(2252.706, currency: 'USD')).to eq(500000000)
63
+ end
64
+
65
+ it "converts btc denomination into satoshi if provided with :btc_denomination" do
66
+ expect(@gateway.amount_from_exchange_rate(5, currency: 'BTC', btc_denomination: :btc)).to eq(500000000)
67
+ end
68
+
69
+ it "accepts string as amount and converts it properly" do
70
+ expect(@gateway.amount_from_exchange_rate('0.5', currency: 'BTC', btc_denomination: :btc)).to eq(50000000)
71
+ end
72
+
73
+ end
74
+
75
+ end