diff --git a/forms/alt_top_up/bch_addresses.rb b/forms/alt_top_up/bch_addresses.rb
index 110fb5f11c7562ffa3847583ecb41e9dd6ba7d1d..9d9a358045c18dfafbdd61f6356d5e5320310ee6 100644
--- a/forms/alt_top_up/bch_addresses.rb
+++ b/forms/alt_top_up/bch_addresses.rb
@@ -1,7 +1,7 @@
DESCRIPTION =
"You can make a Bitcoin Cash payment of any amount to any " \
"of these addresses and it will be credited to your " \
- "account at the Canadian Bitcoins exchange rate within 5 " \
+ "account at the current exchange rate within 5 " \
"minutes of your transaction reaching 3 confirmations.".freeze
if @bch_addresses && !@bch_addresses.empty?
diff --git a/forms/alt_top_up/btc_addresses.rb b/forms/alt_top_up/btc_addresses.rb
index 381fe11996aa301e57ba9ca335ce790d60771305..51803ebd302cdfeda13969414287f0b63b639c77 100644
--- a/forms/alt_top_up/btc_addresses.rb
+++ b/forms/alt_top_up/btc_addresses.rb
@@ -1,7 +1,7 @@
DESCRIPTION =
"You can make a Bitcoin payment of any amount to any " \
"of these addresses and it will be credited to your " \
- "account at the Canadian Bitcoins exchange rate within 5 " \
+ "account at the Bull Bitcoin exchange rate within 5 " \
"minutes of your transaction reaching 3 confirmations.".freeze
if @btc_addresses && !@btc_addresses.empty?
diff --git a/lib/btc_sell_prices.rb b/lib/btc_sell_prices.rb
index e9bd3f7b59518752c707fcddbf213ae6f99bb58b..63d262822aa1a404543fcf2a9309fe1a9eef4c3a 100644
--- a/lib/btc_sell_prices.rb
+++ b/lib/btc_sell_prices.rb
@@ -1,16 +1,18 @@
# frozen_string_literal: true
-require "em-http"
require "em_promise"
-require "em-synchrony/em-http" # For aget vs get
require "money/bank/open_exchange_rates_bank"
-require "nokogiri"
+require_relative "bull_bitcoin"
require_relative "em"
+require_relative "simple_swap"
class CryptoSellPrices
- def initialize(redis, oxr_app_id)
+ class PriceError < StandardError; end
+
+ def initialize(redis, oxr_app_id, bull: BullBitcoin.new)
@redis = redis
+ @bull = bull
@oxr = Money::Bank::OpenExchangeRatesBank.new(
Money::RatesStore::Memory.new
)
@@ -21,38 +23,8 @@ class CryptoSellPrices
EMPromise.all([cad, cad_to_usd]).then { |(a, b)| a * b }
end
- def ticker_row_selector
- raise NotImplementedError, "Subclass must implement"
- end
-
- def crypto_name
- raise NotImplementedError, "Subclass must implement"
- end
-
- def cad
- fetch_canadianbitcoins.then do |http|
- cb = Nokogiri::HTML.parse(http.response)
-
- row = cb.at(ticker_row_selector)
- unless row.at("td").text == crypto_name
- raise "#{crypto_name} row has moved"
- end
-
- BigDecimal(
- row.at("td:nth-of-type(4)").text.match(/^\$(\d+\.\d+)/)[1]
- )
- end
- end
-
protected
- def fetch_canadianbitcoins
- EM::HttpRequest.new(
- "https://www.canadianbitcoins.com",
- tls: { verify_peer: true }
- ).aget
- end
-
def cad_to_usd
@redis.get("cad_to_usd").then do |rate|
next rate.to_f if rate
@@ -69,21 +41,30 @@ protected
end
class BCHSellPrices < CryptoSellPrices
- def crypto_name
- "Bitcoin Cash"
+ def initialize(
+ redis, oxr_app_id,
+ simple_swap: SimpleSwap.new, bull: BullBitcoin.new
+ )
+ super(redis, oxr_app_id, bull: bull)
+ @simple_swap = simple_swap
end
- def ticker_row_selector
- "#ticker > table > tbody > tr:nth-of-type(2)"
+ def cad
+ EMPromise.all([
+ @bull.fetch_btc_cad,
+ @simple_swap.fetch_rate("btc")
+ ]).then do |(btc_cad, bch_per_btc)|
+ unless bch_per_btc&.positive?
+ raise PriceError, "SimpleSwap BTC to BCH rate was not positive"
+ end
+
+ btc_cad / bch_per_btc
+ end
end
end
class BTCSellPrices < CryptoSellPrices
- def crypto_name
- "Bitcoin"
- end
-
- def ticker_row_selector
- "#ticker > table > tbody > tr"
+ def cad
+ @bull.fetch_btc_cad
end
end
diff --git a/lib/bull_bitcoin.rb b/lib/bull_bitcoin.rb
new file mode 100644
index 0000000000000000000000000000000000000000..a80035941d3de2c59ff1ca384495a35753e1ffd7
--- /dev/null
+++ b/lib/bull_bitcoin.rb
@@ -0,0 +1,75 @@
+# frozen_string_literal: true
+
+require "em-http"
+require "em_promise"
+require "em-synchrony/em-http" # For aget vs get
+require "json"
+
+require_relative "em"
+
+class BullBitcoin
+ Error = Class.new(StandardError)
+
+ def fetch_btc_cad
+ EM::HttpRequest.new(
+ "https://www.bullbitcoin.com/api/price",
+ tls: { verify_peer: true }
+ ).apost(
+ head: {
+ "Accept" => "application/json",
+ "Content-Type" => "application/json"
+ },
+ body: {
+ id: request_id,
+ jsonrpc: "2.0",
+ method: "getUserRate",
+ params: {
+ element: {
+ fromCurrency: "BTC",
+ toCurrency: "CAD"
+ }
+ }
+ }.to_json
+ ).then(&method(:parse_response))
+ end
+
+private
+
+ def request_id
+ "#{(Time.now.to_f * 1000).to_i}-#{rand(100...999)}"
+ end
+
+ def parse_response(http)
+ status = http.response_header.status
+ if status != 200
+ raise Error, "Bull Bitcoin price request failed with HTTP #{status}"
+ end
+
+ el = extract_element(http.response)
+ BigDecimal(el.fetch("userPrice").to_s) / (10**el.fetch("precision"))
+ rescue JSON::ParserError, KeyError => e
+ raise Error, "Bull Bitcoin price response invalid: #{e.message}"
+ end
+
+ def extract_element(response)
+ json = JSON.parse(response)
+ if json["error"]
+ raise Error,
+ "Bull Bitcoin price response error: #{format_error(json['error'])}"
+ end
+
+ el = json.dig("result", "element")
+ return el if el
+
+ raise Error, "Bull Bitcoin price response missing result.element"
+ end
+
+ def format_error(error)
+ if error["message"] == "Validation error" && error["data"].is_a?(Hash)
+ nested = error.dig("data", "apiError", "message")
+ return "#{error['message']}: #{nested}" if nested
+ end
+
+ error["message"]
+ end
+end
diff --git a/test/test_bch_sell_prices.rb b/test/test_bch_sell_prices.rb
index 9b8da9481b715e9c9ce368e026c240ff6ed493bb..6ce13c01ecdf8f80532a2424326c7370501ff6e7 100644
--- a/test/test_bch_sell_prices.rb
+++ b/test/test_bch_sell_prices.rb
@@ -7,35 +7,62 @@ require "btc_sell_prices"
class BCHSellPricesTest < Minitest::Test
def setup
@redis = Minitest::Mock.new
- @subject = BCHSellPrices.new(@redis, "")
+ @bull = Minitest::Mock.new
+ @simple_swap = Minitest::Mock.new
+ @subject = BCHSellPrices.new(
+ @redis, "", simple_swap: @simple_swap, bull: @bull
+ )
end
def test_cad
- stub_request(:get, "https://www.canadianbitcoins.com").to_return(
- body: "
" \
- "" \
- "| Monopoly Money | | | 10 trillion | " \
- "
" \
- "" \
- "| Bitcoin Cash | | | $123.00 | " \
- "
"
+ @bull.expect(
+ :fetch_btc_cad,
+ EMPromise.resolve(BigDecimal("100"))
+ )
+ @simple_swap.expect(
+ :fetch_rate,
+ EMPromise.resolve(BigDecimal("20")),
+ ["btc"]
)
- assert_equal BigDecimal(123), @subject.cad.sync
+ assert_equal BigDecimal("5"), @subject.cad.sync
+ assert_mock @bull
+ assert_mock @simple_swap
end
em :test_cad
+ def test_cad_raises_on_zero_bch_rate
+ @bull.expect(
+ :fetch_btc_cad,
+ EMPromise.resolve(BigDecimal("100"))
+ )
+ @simple_swap.expect(
+ :fetch_rate,
+ EMPromise.resolve(BigDecimal("0")),
+ ["btc"]
+ )
+
+ error = assert_raises(CryptoSellPrices::PriceError) { @subject.cad.sync }
+ assert_equal "SimpleSwap BTC to BCH rate was not positive", error.message
+ assert_mock @bull
+ assert_mock @simple_swap
+ end
+ em :test_cad_raises_on_zero_bch_rate
+
def test_usd
- stub_request(:get, "https://www.canadianbitcoins.com").to_return(
- body: "" \
- "" \
- "| Monopoly Money | | | 10 trillion | " \
- "
" \
- "" \
- "| Bitcoin Cash | | | $123.00 | " \
- "
"
+ @bull.expect(
+ :fetch_btc_cad,
+ EMPromise.resolve(BigDecimal("100"))
+ )
+ @simple_swap.expect(
+ :fetch_rate,
+ EMPromise.resolve(BigDecimal("20")),
+ ["btc"]
)
@redis.expect(:get, EMPromise.resolve("0.5"), ["cad_to_usd"])
- assert_equal BigDecimal(123) / 2, @subject.usd.sync
+ assert_equal BigDecimal("2.5"), @subject.usd.sync
+ assert_mock @bull
+ assert_mock @simple_swap
+ assert_mock @redis
end
em :test_usd
end
diff --git a/test/test_btc_sell_prices.rb b/test/test_btc_sell_prices.rb
index 584a5737c101d63065568790dae0cf236f321900..78cd3af122432f72f2c642f95c9f13ca88babe70 100644
--- a/test/test_btc_sell_prices.rb
+++ b/test/test_btc_sell_prices.rb
@@ -7,25 +7,29 @@ require "btc_sell_prices"
class BTCSellPricesTest < Minitest::Test
def setup
@redis = Minitest::Mock.new
- @subject = BTCSellPrices.new(@redis, "")
+ @bull = Minitest::Mock.new
+ @subject = BTCSellPrices.new(@redis, "", bull: @bull)
end
def test_cad
- stub_request(:get, "https://www.canadianbitcoins.com").to_return(
- body: "" \
- "| Bitcoin | | | $123.00 | "
+ @bull.expect(
+ :fetch_btc_cad,
+ EMPromise.resolve(BigDecimal("123.45"))
)
- assert_equal BigDecimal(123), @subject.cad.sync
+ assert_equal BigDecimal("123.45"), @subject.cad.sync
+ assert_mock @bull
end
em :test_cad
def test_usd
- stub_request(:get, "https://www.canadianbitcoins.com").to_return(
- body: "" \
- "| Bitcoin | | | $123.00 | "
+ @bull.expect(
+ :fetch_btc_cad,
+ EMPromise.resolve(BigDecimal("123.45"))
)
@redis.expect(:get, EMPromise.resolve("0.5"), ["cad_to_usd"])
- assert_equal BigDecimal(123) / 2, @subject.usd.sync
+ assert_equal BigDecimal("61.725"), @subject.usd.sync
+ assert_mock @bull
+ assert_mock @redis
end
em :test_usd
end
diff --git a/test/test_bull_bitcoin.rb b/test/test_bull_bitcoin.rb
new file mode 100644
index 0000000000000000000000000000000000000000..ed260d3e37ade46321fc091e398875bc47c77df0
--- /dev/null
+++ b/test/test_bull_bitcoin.rb
@@ -0,0 +1,140 @@
+# frozen_string_literal: true
+
+require "test_helper"
+require "bull_bitcoin"
+
+class BullBitcoinTest < Minitest::Test
+ def setup
+ @subject = BullBitcoin.new
+ end
+
+ def stub_bull_btc_cad(user_price: 12_345, precision: 2)
+ stub_request(:post, "https://www.bullbitcoin.com/api/price").with { |req|
+ body = JSON.parse(req.body)
+ expected = {
+ "jsonrpc" => "2.0",
+ "method" => "getUserRate",
+ "params" => {
+ "element" => {
+ "fromCurrency" => "BTC",
+ "toCurrency" => "CAD"
+ }
+ }
+ }
+ req.headers["Content-Type"] == "application/json" &&
+ body["id"] =~ /^\d{13}-\d{3}$/ &&
+ body.slice("jsonrpc", "method", "params") == expected
+ }.to_return(
+ status: 200,
+ body: {
+ jsonrpc: "2.0",
+ result: {
+ element: {
+ userPrice: user_price,
+ precision: precision
+ }
+ }
+ }.to_json
+ )
+ end
+
+ def test_fetch_btc_cad
+ stub_bull_btc_cad
+ assert_equal BigDecimal("123.45"), @subject.fetch_btc_cad.sync
+ end
+ em :test_fetch_btc_cad
+
+ def test_fetch_btc_cad_raises_on_http_error
+ stub_request(:post, "https://www.bullbitcoin.com/api/price").to_return(
+ status: 500,
+ body: "Internal Server Error"
+ )
+
+ error = assert_raises(BullBitcoin::Error) { @subject.fetch_btc_cad.sync }
+ assert_equal(
+ "Bull Bitcoin price request failed with HTTP 500",
+ error.message
+ )
+ end
+ em :test_fetch_btc_cad_raises_on_http_error
+
+ def test_fetch_btc_cad_raises_on_missing_element
+ stub_request(:post, "https://www.bullbitcoin.com/api/price").to_return(
+ status: 200,
+ body: { jsonrpc: "2.0", result: {} }.to_json
+ )
+
+ error = assert_raises(BullBitcoin::Error) { @subject.fetch_btc_cad.sync }
+ assert_equal(
+ "Bull Bitcoin price response missing result.element",
+ error.message
+ )
+ end
+ em :test_fetch_btc_cad_raises_on_missing_element
+
+ def test_fetch_btc_cad_raises_on_validation_error_with_nested_data
+ stub_request(:post, "https://www.bullbitcoin.com/api/price").to_return(
+ status: 200,
+ body: {
+ jsonrpc: "2.0",
+ error: {
+ code: -32602,
+ message: "Validation error",
+ data: {
+ apiError: {
+ code: "ERR_API_400",
+ message: "Invalid request parameters"
+ }
+ }
+ }
+ }.to_json
+ )
+
+ error = assert_raises(BullBitcoin::Error) { @subject.fetch_btc_cad.sync }
+ expected = "Bull Bitcoin price response error: " \
+ "Validation error: Invalid request parameters"
+ assert_equal(expected, error.message)
+ end
+ em :test_fetch_btc_cad_raises_on_validation_error_with_nested_data
+
+ def test_fetch_btc_cad_raises_on_validation_error_with_string_data
+ stub_request(:post, "https://www.bullbitcoin.com/api/price").to_return(
+ status: 200,
+ body: {
+ jsonrpc: "2.0",
+ error: {
+ code: -32602,
+ message: "Validation error",
+ data: "4667aad3bf0ef71f:3017b8b112de44f2"
+ }
+ }.to_json
+ )
+
+ error = assert_raises(BullBitcoin::Error) { @subject.fetch_btc_cad.sync }
+ assert_equal(
+ "Bull Bitcoin price response error: Validation error",
+ error.message
+ )
+ end
+ em :test_fetch_btc_cad_raises_on_validation_error_with_string_data
+
+ def test_fetch_btc_cad_raises_on_standard_json_rpc_error
+ stub_request(:post, "https://www.bullbitcoin.com/api/price").to_return(
+ status: 200,
+ body: {
+ jsonrpc: "2.0",
+ error: {
+ code: -32603,
+ message: "No Validation schema found for [notARealMethod]",
+ data: "4667aad3bf0ef71f:3017b8b112de44f2:6e6c5c73209cfe6a:0"
+ }
+ }.to_json
+ )
+
+ error = assert_raises(BullBitcoin::Error) { @subject.fetch_btc_cad.sync }
+ expected = "Bull Bitcoin price response error: " \
+ "No Validation schema found for [notARealMethod]"
+ assert_equal(expected, error.message)
+ end
+ em :test_fetch_btc_cad_raises_on_standard_json_rpc_error
+end