Endpoint that pushes all unknown transactions into Redis

Stephen Paul Weber created

The intent is to use `electrum notify <address> <app>/electrum_noify?address=&customer_id=`

The app asks electrum for all transactions on that address, and then checks
which ones we *don't* already have recorded in the transactions table.  These
are pushed into Redis to be picked up by a to-be-written job that will write
them to the transactions table after 3 confirmations.

Change summary

.builds/debian-stable.yml |  1 
.gitignore                |  2 
.gitmodules               |  3 +
.rubocop.yml              |  4 ++
Gemfile                   |  1 
config.ru                 | 66 +++++++++++++++++++++++++++++++++++++++++
lib/electrum.rb           | 41 +++++++++++++++++++++++++
schemas                   |  1 
8 files changed, 118 insertions(+), 1 deletion(-)

Detailed changes

.gitignore 🔗

@@ -1,4 +1,4 @@
 .bundle
 .gems
 Gemfile.lock
-braintree.dhall
+*.dhall

.gitmodules 🔗

@@ -0,0 +1,3 @@
+[submodule "schemas"]
+	path = schemas
+	url = https://git.singpolyma.net/jmp-schemas

.rubocop.yml 🔗

@@ -1,6 +1,10 @@
 Metrics/LineLength:
   Max: 80
 
+Metrics/BlockLength:
+  ExcludedMethods:
+    - route
+
 Layout/Tab:
   Enabled: false
 

Gemfile 🔗

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

config.ru 🔗

@@ -3,11 +3,21 @@
 require "braintree"
 require "delegate"
 require "dhall"
+require "pg"
 require "redis"
 require "roda"
 
+require_relative "lib/electrum"
+
 REDIS = Redis.new
 BRAINTREE_CONFIG = Dhall.load("env:BRAINTREE_CONFIG").sync
+ELECTRUM = Electrum.new(
+	**Dhall::Coder.load("env:ELECTRUM_CONFIG", transform_keys: :to_sym)
+)
+
+DB = PG.connect(dbname: "jmp")
+DB.type_map_for_results = PG::BasicTypeMapForResults.new(DB)
+DB.type_map_for_queries = PG::BasicTypeMapForQueries.new(DB)
 
 class CreditCardGateway
 	def initialize(jid, customer_id=nil)
@@ -77,11 +87,67 @@ protected
 	end
 end
 
+class UnknownTransactions
+	def self.from(customer_id, address, tx_hashes)
+		values = tx_hashes.map do |tx_hash|
+			"('#{DB.escape_string(tx_hash)}/#{DB.escape_string(address)}')"
+		end
+		rows = DB.exec_params(<<-SQL)
+			SELECT transaction_id FROM
+				(VALUES #{values.join(',')}) AS t(transaction_id)
+				LEFT JOIN transactions USING (transaction_id)
+			WHERE transactions.transaction_id IS NULL
+		SQL
+		new(customer_id, rows.map { |row| row["transaction_id"] })
+	end
+
+	def initialize(customer_id, transaction_ids)
+		@customer_id = customer_id
+		@transaction_ids = transaction_ids
+	end
+
+	def enqueue!
+		REDIS.hset(
+			"pending_btc_transactions",
+			*@transaction_ids.flat_map { |txid| [txid, @customer_id] }
+		)
+	end
+end
+
 class JmpPay < Roda
 	plugin :render, engine: "slim"
 	plugin :common_logger, $stdout
 
+	def redis_key_btc_addresses
+		"jmp_customer_btc_addresses-#{request.params['customer_id']}"
+	end
+
+	def verify_address_customer_id(r)
+		return if REDIS.sismember(redis_key_btc_addresses, request.params["address"])
+
+		warn "Address and customer_id do not match"
+		r.halt(
+			403,
+			{"Content-Type" => "text/plain"},
+			"Address and customer_id do not match"
+		)
+	end
+
 	route do |r|
+		r.on "electrum_notify" do
+			verify_address_customer_id(r)
+
+			UnknownTransactions.from(
+				request.params["customer_id"],
+				request.params["address"],
+				ELECTRUM
+					.getaddresshistory(request.params["address"])
+					.map { |item| item["tx_hash"] }
+			).enqueue!
+
+			"OK"
+		end
+
 		r.on :jid do |jid|
 			r.on "credit_cards" do
 				gateway = CreditCardGateway.new(

lib/electrum.rb 🔗

@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+require "json"
+require "net/http"
+require "securerandom"
+
+class Electrum
+	def initialize(rpc_uri:, rpc_username:, rpc_password:)
+		@rpc_uri = URI(rpc_uri)
+		@rpc_username = rpc_username
+		@rpc_password = rpc_password
+	end
+
+	def getaddresshistory(address)
+		rpc_call(:getaddresshistory, address: address)["result"]
+	end
+
+protected
+
+	def rpc_call(method, params)
+		JSON.parse(post_json(
+			jsonrpc: "2.0",
+			id: SecureRandom.hex,
+			method: method.to_s,
+			params: params
+		).body)
+	end
+
+	def post_json(data)
+		req = Net::HTTP::Post.new(@rpc_uri, "Content-Type" => "application/json")
+		req.basic_auth(@rpc_username, @rpc_password)
+		req.body = data.to_json
+		Net::HTTP.start(
+			@rpc_uri.hostname,
+			@rpc_uri.port,
+			use_ssl: @rpc_uri.scheme == "https"
+		) do |http|
+			http.request(req)
+		end
+	end
+end

schemas 🔗

@@ -0,0 +1 @@
+Subproject commit 3e0d7e8ae7193f567294036c3235d50ed318b945