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