Cronjob to check pending BTC transactions

Stephen Paul Weber created

When they become confirmed, insert them into the transactions table.
We can never double-insert because of the PRIMARY KEY on transaction_id, so the
script is always safe to run even if something ends up in redis twice.

Change summary

.rubocop.yml                         |  3 
Gemfile                              |  1 
bin/process_pending_btc_transactions | 98 ++++++++++++++++++++++++++++++
lib/electrum.rb                      | 33 ++++++++++
4 files changed, 135 insertions(+)

Detailed changes

.rubocop.yml 🔗

@@ -1,3 +1,6 @@
+AllCops:
+  TargetRubyVersion: 2.3
+
 Metrics/LineLength:
   Max: 80
 

Gemfile 🔗

@@ -4,6 +4,7 @@ source "https://rubygems.org"
 
 gem "braintree"
 gem "dhall"
+gem "money-open-exchange-rates"
 gem "pg"
 gem "redis"
 gem "roda"

bin/process_pending_btc_transactions 🔗

@@ -0,0 +1,98 @@
+#!/usr/bin/ruby
+# frozen_string_literal: true
+
+# Usage: bin/process_pending-btc_transactions '{
+#        oxr_app_id = "",
+#        required_confirmations = 3,
+#        electrum = env:ELECTRUM_CONFIG,
+#        plans = ./plans.dhall
+#        }'
+
+require "bigdecimal"
+require "dhall"
+require "money/bank/open_exchange_rates_bank"
+require "net/http"
+require "nokogiri"
+require "pg"
+require "redis"
+
+require_relative "../lib/electrum"
+
+CONFIG =
+	Dhall::Coder
+	.new(safe: Dhall::Coder::JSON_LIKE + [Symbol])
+	.load(ARGV[0], transform_keys: :to_sym)
+
+REDIS = Redis.new
+ELECTRUM = Electrum.new(**CONFIG[:electrum])
+
+DB = PG.connect(dbname: "jmp")
+DB.type_map_for_results = PG::BasicTypeMapForResults.new(DB)
+DB.type_map_for_queries = PG::BasicTypeMapForQueries.new(DB)
+
+unless (cad_to_usd = REDIS.get("cad_to_usd")&.to_f)
+	oxr = Money::Bank::OpenExchangeRatesBank.new(Money::RatesStore::Memory.new)
+	oxr.app_id = CONFIG.fetch(:oxr_app_id)
+	oxr.update_rates
+	cad_to_usd = oxr.get_rate("CAD", "USD")
+	REDIS.set("cad_to_usd", cad_to_usd, ex: 60*60)
+end
+
+canadianbitcoins = Nokogiri::HTML.parse(
+	Net::HTTP.get(URI("https://www.canadianbitcoins.com"))
+)
+
+bitcoin_row = canadianbitcoins.at("#ticker > table > tbody > tr")
+raise "Bitcoin row has moved" unless bitcoin_row.at("td").text == "Bitcoin"
+
+btc_sell_price = {}
+btc_sell_price[:CAD] = BigDecimal.new(
+	bitcoin_row.at("td:nth-of-type(3)").text.match(/^\$(\d+\.\d+)/)[1]
+)
+btc_sell_price[:USD] = btc_sell_price[:CAD] * cad_to_usd
+
+class Plan
+	def self.for_customer(customer_id)
+		row = DB.exec_params(<<-SQL, [customer_id]).first
+			SELECT plan_name FROM customer_plans WHERE customer_id=$1 LIMIT 1
+		SQL
+		return unless row
+		plan = CONFIG[:plans].find { |p| p["plan_name"] = row["plan_name"] }
+		new(plan) if plan
+	end
+
+	def initialize(plan)
+		@plan = plan
+	end
+
+	def currency
+		@plan[:currency]
+	end
+end
+
+REDIS.hgetall("pending_btc_transactions").each do |(txid, customer_id)|
+	tx_hash, address = txid.split("/", 2)
+	transaction = ELECTRUM.gettransaction(tx_hash)
+	next unless transaction.confirmations >= CONFIG[:required_confirmations]
+	btc = transaction.amount_for(address)
+	if btc <= 0
+		warn "Transaction shows as #{btc}, skipping #{txid}"
+		next
+	end
+	DB.transaction do
+		plan = Plan.for_customer(customer_id)
+		if plan
+			amount = btc * btc_sell_price.fetch(plan.currency).round(4, :floor)
+			DB.exec_params(<<-SQL, [customer_id, txid, amount])
+				INSERT INTO transactions
+					(customer_id, transaction_id, amount, note)
+				VALUES
+						($1, $2, $3, 'Bitcoin payment')
+				ON CONFLICT (transaction_id) DO NOTHING
+			SQL
+		else
+			warn "No plan for #{customer_id} cannot save #{txid}"
+		end
+	end
+	REDIS.hdel("pending_btc_transactions", txid)
+end

lib/electrum.rb 🔗

@@ -1,5 +1,6 @@
 # frozen_string_literal: true
 
+require "bigdecimal"
 require "json"
 require "net/http"
 require "securerandom"
@@ -15,6 +16,38 @@ class Electrum
 		rpc_call(:getaddresshistory, address: address)["result"]
 	end
 
+	def gettransaction(tx_hash)
+		Transaction.new(self, tx_hash, rpc_call(
+			:deserialize,
+			[rpc_call(:gettransaction, txid: tx_hash)["result"]]
+		)["result"])
+	end
+
+	def get_tx_status(tx_hash)
+		rpc_call(:get_tx_status, txid: tx_hash)["result"]
+	end
+
+	class Transaction
+		def initialize(electrum, tx_hash, tx)
+			@electrum = electrum
+			@tx_hash = tx_hash
+			@tx = tx
+		end
+
+		def confirmations
+			@electrum.get_tx_status(@tx_hash)["confirmations"]
+		end
+
+		def amount_for(*addresses)
+			BigDecimal.new(
+				@tx["outputs"]
+					.select { |o| addresses.include?(o["address"]) }
+					.map { |o| o["value_sats"] }
+					.sum
+			) * 0.00000001
+		end
+	end
+
 protected
 
 	def rpc_call(method, params)