straight 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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