Allow electrum of alternate currency

Stephen Paul Weber created

Change summary

bin/check_electrum_wallet_completeness |  3 +
bin/detect_duplicate_addrs             |  3 +
bin/get_available_addresses            |  2 
bin/process_pending_btc_transactions   | 40 ++++++++++++++-------
bin/reassert_electrum_notification     |  8 ++-
config.ru                              | 31 +++++++++++++---
lib/electrum.rb                        |  7 ++-
lib/redis_addresses.rb                 | 51 +++++++++++++++------------
8 files changed, 94 insertions(+), 51 deletions(-)

Detailed changes

bin/check_electrum_wallet_completeness 🔗

@@ -16,7 +16,8 @@ electrum = Electrum.new(**config)
 
 electrum_addrs = electrum.listaddresses
 
-get_addresses_with_users(redis).each do |addr, keys|
+addrs = RedisAddresses.new(redis, config[:currency])
+addrs.get_addresses_with_users.each do |addr, keys|
 	unless electrum_addrs.include?(addr)
 		puts "The address #{addr} (included in #{keys.join(', ')}) "\
 			"isn't included in electrum's list"

bin/detect_duplicate_addrs 🔗

@@ -6,7 +6,8 @@ require_relative "../lib/redis_addresses"
 
 redis = Redis.new
 
-get_addresses_with_users(redis).each do |addr, keys|
+addrs = RedisAddresses.new(redis, "btc")
+addrs.get_addresses_with_users(redis).each do |addr, keys|
 	if keys.length > 1
 		puts "#{addr} is used by the following " \
 		     "#{keys.length} keys: #{keys.join(' ')}"

bin/get_available_addresses 🔗

@@ -17,7 +17,7 @@ electrum = Electrum.new(**config)
 
 addresses = Set.new(electrum.listaddresses)
 
-RedisBtcAddresses.each_user(redis) do |_, addrs|
+RedisAddresses.new(redis, config[:currency]).each_user do |_, addrs|
 	addresses.subtract(addrs)
 end
 

bin/process_pending_btc_transactions 🔗

@@ -56,14 +56,22 @@ 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"
+case CONFIG[:electrum][:currency]
+	when "btc"
+		exchange_row = canadianbitcoins.at("#ticker > table > tbody > tr")
+		raise "Bitcoin row has moved" unless exchange_row.at("td").text == "Bitcoin"
+	when "bch"
+		exchange_row = canadianbitcoins.at("#ticker > table > tbody > tr:nth-child(2)")
+		raise "Bitcoin Cash row has moved" unless exchange_row.at("td").text == "Bitcoin Cash"
+	else
+		raise "Unknown currency #{CONFIG[:electrum][:currency]}"
+end
 
-btc_sell_price = {}
-btc_sell_price[:CAD] = BigDecimal(
-	bitcoin_row.at("td:nth-of-type(3)").text.match(/^\$(\d+\.\d+)/)[1]
+sell_price = {}
+sell_price[:CAD] = BigDecimal(
+	exchange_row.at("td:nth-of-type(4)").text.match(/^\$(\d+\.\d+)/)[1]
 )
-btc_sell_price[:USD] = btc_sell_price[:CAD] * cad_to_usd
+sell_price[:USD] = sell_price[:CAD] * cad_to_usd
 
 class Plan
 	def self.for_customer(customer)
@@ -180,7 +188,12 @@ class Customer
 	end
 
 	def add_btc_credit(txid, btc_amount, fiat_amount)
-		tx = Transaction.new(@customer_id, txid, fiat_amount, "Bitcoin payment")
+		tx = Transaction.new(
+			@customer_id,
+			txid,
+			fiat_amount,
+			"Cryptocurrency payment"
+		)
 		return unless tx.save
 
 		tx.bonus&.save
@@ -190,15 +203,16 @@ class Customer
 	def notify_btc_credit(txid, btc_amount, fiat_amount, bonus)
 		tx_hash, = txid.split("/", 2)
 		notify([
-			"Your Bitcoin transaction of #{btc_amount.to_s('F')} BTC ",
-			"has been added as $#{'%.4f' % fiat_amount} (#{plan.currency}) ",
+			"Your transaction of #{btc_amount.to_s('F')} ",
+			CONFIG[:electrum][:currency],
+			" has been added as $#{'%.4f' % fiat_amount} (#{plan.currency}) ",
 			("+ $#{'%.4f' % bonus} bonus " if bonus.positive?),
 			"to your account.\n(txhash: #{tx_hash})"
 		].compact.join)
 	end
 end
 
-done = REDIS.hgetall("pending_btc_transactions").map { |(txid, customer_id)|
+done = REDIS.hgetall("pending_#{CONFIG[:electrum][:currency]}_transactions").map { |(txid, customer_id)|
 	tx_hash, address = txid.split("/", 2)
 
 	transaction = begin
@@ -213,16 +227,16 @@ done = REDIS.hgetall("pending_btc_transactions").map { |(txid, customer_id)|
 	btc = transaction.amount_for(address)
 	if btc <= 0
 		# This is a send, not a receive, do not record it
-		REDIS.hdel("pending_btc_transactions", txid)
+		REDIS.hdel("pending_#{CONFIG[:electrum][:currency]}_transactions", txid)
 		next
 	end
 	DB.transaction do
 		customer = Customer.new(customer_id)
 		if (plan = customer.plan)
-			amount = btc * btc_sell_price.fetch(plan.currency).round(4, :floor)
+			amount = btc * sell_price.fetch(plan.currency).round(4, :floor)
 			customer.add_btc_credit(txid, btc, amount)
 			plan.notify_any_pending_plan!
-			REDIS.hdel("pending_btc_transactions", txid)
+			REDIS.hdel("pending_#{CONFIG[:electrum][:currency]}_transactions", txid)
 			txid
 		else
 			warn "No plan for #{customer_id} cannot save #{txid}"

bin/reassert_electrum_notification 🔗

@@ -13,8 +13,9 @@ config =
 
 redis = Redis.new
 electrum = Electrum.new(**config)
+addrs = RedisAddresses.new(redis, config[:currency])
 
-get_addresses_with_users(redis).each do |addr, keys|
+addrs.get_addresses_with_users(redis).each do |addr, keys|
 	match = keys.first.match(/.*-(\d+)$/)
 	unless match
 		puts "Can't understand key #{keys.first}, skipping"
@@ -22,8 +23,9 @@ get_addresses_with_users(redis).each do |addr, keys|
 	end
 
 	customer_id = match[1]
-	url = "https://pay.jmp.chat/electrum_notify?"\
-		"address=#{addr}&customer_id=#{customer_id}"
+	url = "https://pay.jmp.chat/electrum_notify?" \
+		"address=#{addr}&customer_id=#{customer_id}" \
+		"&currency=#{config[:currency]}"
 
 	unless electrum.notify(addr, url)
 		puts "Failed to setup #{addr} to notify #{url}. Skipping"

config.ru 🔗

@@ -34,6 +34,9 @@ BRAINTREE_CONFIG = Dhall.load("env:BRAINTREE_CONFIG").sync
 ELECTRUM = Electrum.new(
 	**Dhall::Coder.load("env:ELECTRUM_CONFIG", transform_keys: :to_sym)
 )
+ELECTRUM_BCH = Electrum.new(
+	**Dhall::Coder.load("env:ELECTRON_CASH_CONFIG", transform_keys: :to_sym)
+)
 
 DB = PG.connect(dbname: "jmp")
 DB.type_map_for_results = PG::BasicTypeMapForResults.new(DB)
@@ -161,8 +164,9 @@ class CreditCardGateway
 end
 
 class UnknownTransactions
-	def self.from(customer_id, address, tx_hashes)
+	def self.from(currency, customer_id, address, tx_hashes)
 		self.for(
+			currency,
 			customer_id,
 			fetch_rows_for(address, tx_hashes).map { |row|
 				row["transaction_id"]
@@ -184,18 +188,21 @@ class UnknownTransactions
 		SQL
 	end
 
-	def self.for(customer_id, transaction_ids)
-		transaction_ids.empty? ? None.new : new(customer_id, transaction_ids)
+	def self.for(currency, customer_id, transaction_ids)
+		return None.new if transaction_ids.empty?
+
+		new(currency, customer_id, transaction_ids)
 	end
 
-	def initialize(customer_id, transaction_ids)
+	def initialize(currency, customer_id, transaction_ids)
+		@currency = currency
 		@customer_id = customer_id
 		@transaction_ids = transaction_ids
 	end
 
 	def enqueue!
 		REDIS.hset(
-			"pending_btc_transactions",
+			"pending_#{@currency}_transactions",
 			*@transaction_ids.flat_map { |txid| [txid, @customer_id] }
 		)
 	end
@@ -269,8 +276,17 @@ class JmpPay < Roda
 	extend Forwardable
 	def_delegators :request, :params
 
+	def electrum
+		case params["currency"]
+		when "bch"
+			ELECTRUM_BCH
+		else
+			ELECTRUM
+		end
+	end
+
 	def redis_key_btc_addresses
-		"jmp_customer_btc_addresses-#{params['customer_id']}"
+		"jmp_customer_#{electrum.currency}_addresses-#{params['customer_id']}"
 	end
 
 	def verify_address_customer_id(r)
@@ -289,9 +305,10 @@ class JmpPay < Roda
 			verify_address_customer_id(r)
 
 			UnknownTransactions.from(
+				electrum.currency,
 				params["customer_id"],
 				params["address"],
-				ELECTRUM
+				electrum
 					.getaddresshistory(params["address"])
 					.map { |item| item["tx_hash"] }
 			).enqueue!

lib/electrum.rb 🔗

@@ -8,10 +8,13 @@ require "securerandom"
 class Electrum
 	class NoTransaction < StandardError; end
 
-	def initialize(rpc_uri:, rpc_username:, rpc_password:)
+	attr_reader :currency
+
+	def initialize(rpc_uri:, rpc_username:, rpc_password:, currency:)
 		@rpc_uri = URI(rpc_uri)
 		@rpc_username = rpc_username
 		@rpc_password = rpc_password
+		@currency = currency
 	end
 
 	def getaddresshistory(address)
@@ -39,7 +42,7 @@ class Electrum
 
 	class Transaction
 		def initialize(electrum, tx_hash, tx)
-			raise NoTransaction, "No tx found for #{tx_hash}" unless tx
+			raise NoTransaction, "No tx for #{@currency} #{tx_hash}" unless tx
 
 			@electrum = electrum
 			@tx_hash = tx_hash

lib/redis_addresses.rb 🔗

@@ -2,39 +2,44 @@
 
 require "redis"
 
-# This returns a hash
-# The keys are the bitcoin addresses, the values are all of the keys which
-#   contain that address
-# If there are no duplicates, then each value will be a singleton list
-def get_addresses_with_users(redis)
-	addrs = Hash.new { |h, k| h[k] = [] }
-
-	# I picked 1000 because it made a relatively trivial case take 15 seconds
-	#   instead of forever.
-	# Basically it's "how long does each command take"
-	# The lower it is (default is 10), it will go back and forth to the client a
-	#   ton
-	redis.scan_each(match: "jmp_customer_btc_addresses-*", count: 1000) do |key|
-		redis.smembers(key).each do |addr|
-			addrs[addr] << key
-		end
+class RedisAddresses
+	def initialize(redis, currency)
+		@redis = redis
+		@currency = currency.downcase
 	end
 
-	addrs
-end
-
-module RedisBtcAddresses
-	def self.each_user(redis)
+	def each_user(redis)
 		# I picked 1000 because it made a relatively trivial case take
 		# 15 seconds instead of forever.
 		# Basically it's "how long does each command take"
 		# The lower it is (default is 10), it will go back and forth
 		# to the client a ton
-		redis.scan_each(
-			match: "jmp_customer_btc_addresses-*",
+		@redis.scan_each(
+			match: "jmp_customer_#{@currency}_addresses-*",
 			count: 1000
 		) do |key|
 			yield key, redis.smembers(key)
 		end
 	end
+
+	# This returns a hash
+	# The keys are the bitcoin addresses, the values are all of the keys which
+	#   contain that address
+	# If there are no duplicates, then each value will be a singleton list
+	def get_addresses_with_users
+		addrs = Hash.new { |h, k| h[k] = [] }
+
+		# 1000 because it made a relatively trivial case take 15 seconds
+		# instead of forever.
+		# Basically it's "how long does each command take"
+		# The lower it is (default is 10), it will go back and forth
+		# to the client a ton
+		@redis.scan_each(match: "jmp_customer_#{@currency}_addresses-*", count: 1000) do |key|
+			@redis.smembers(key).each do |addr|
+				addrs[addr] << key
+			end
+		end
+
+		addrs
+	end
 end