From 726e71ba549a792856ec3c4455012bcac46589a4 Mon Sep 17 00:00:00 2001 From: Amolith Date: Mon, 30 Mar 2026 15:09:52 -0600 Subject: [PATCH] Replace Canadian Bitcoins with Bull Bitcoin --- 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(-) create mode 100644 lib/bull_bitcoin.rb create mode 100644 test/test_bull_bitcoin.rb 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: "
" \ - "" \ - "" \ - "" \ - "" \ - "" \ - "" + @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 Money10 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: "
Monopoly Money10 trillion
Bitcoin Cash$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
Bitcoin$123.00