Replace Canadian Bitcoins with Bull Bitcoin

Amolith created

Change summary

forms/alt_top_up/bch_addresses.rb |   2 
forms/alt_top_up/btc_addresses.rb |   2 
lib/btc_sell_prices.rb            |  69 +++++----------
lib/bull_bitcoin.rb               |  75 +++++++++++++++++
test/test_bch_sell_prices.rb      |  65 ++++++++++----
test/test_btc_sell_prices.rb      |  22 +++--
test/test_bull_bitcoin.rb         | 140 +++++++++++++++++++++++++++++++++
7 files changed, 301 insertions(+), 74 deletions(-)

Detailed changes

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?

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?

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

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

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: "<div id='ticker'><table><tbody>" \
-			      "<tr>" \
-				  "<td>Monopoly Money</td><td></td><td></td><td>10 trillion</td>" \
-				  "</tr>" \
-				  "<tr>" \
-				  "<td>Bitcoin Cash</td><td></td><td></td><td>$123.00</td>" \
-				  "</tr>"
+		@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: "<div id='ticker'><table><tbody>" \
-			      "<tr>" \
-				  "<td>Monopoly Money</td><td></td><td></td><td>10 trillion</td>" \
-				  "</tr>" \
-				  "<tr>" \
-				  "<td>Bitcoin Cash</td><td></td><td></td><td>$123.00</td>" \
-				  "</tr>"
+		@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

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: "<div id='ticker'><table><tbody><tr>" \
-			      "<td>Bitcoin</td><td></td><td></td><td>$123.00</td>"
+		@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: "<div id='ticker'><table><tbody><tr>" \
-			      "<td>Bitcoin<td></td><td></td><td>$123.00</td>"
+		@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

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