straight 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.document +5 -0
- data/.rspec +1 -0
- data/Gemfile +17 -0
- data/Gemfile.lock +76 -0
- data/LICENSE.txt +20 -0
- data/README.md +132 -0
- data/Rakefile +25 -0
- data/VERSION +1 -0
- data/lib/straight/blockchain_adapter.rb +49 -0
- data/lib/straight/blockchain_adapters/blockchain_info_adapter.rb +84 -0
- data/lib/straight/blockchain_adapters/helloblock_io_adapter.rb +53 -0
- data/lib/straight/exchange_rate_adapter.rb +45 -0
- data/lib/straight/exchange_rate_adapters/bitpay_adapter.rb +19 -0
- data/lib/straight/exchange_rate_adapters/bitstamp_adapter.rb +17 -0
- data/lib/straight/exchange_rate_adapters/coinbase_adapter.rb +19 -0
- data/lib/straight/gateway.rb +182 -0
- data/lib/straight/order.rb +195 -0
- data/lib/straight.rb +18 -0
- data/spec/lib/blockchain_adapters/blockchain_info_spec.rb +52 -0
- data/spec/lib/blockchain_adapters/helloblock_io_spec.rb +43 -0
- data/spec/lib/exchange_rate_adapter_spec.rb +50 -0
- data/spec/lib/exchange_rate_adapters/bitpay_adapter_spec.rb +14 -0
- data/spec/lib/exchange_rate_adapters/bitstamp_adapter_spec.rb +14 -0
- data/spec/lib/exchange_rate_adapters/coinbase_adapter_spec.rb +14 -0
- data/spec/lib/gateway_spec.rb +75 -0
- data/spec/lib/order_spec.rb +112 -0
- data/spec/spec_helper.rb +1 -0
- metadata +144 -0
@@ -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
|