Helpers for doing Electrum RPC

Stephen Paul Weber created

Change summary

lib/electrum.rb       |  80 +++++++++++++++++++++++++++++++++
test/test_electrum.rb | 106 +++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 186 insertions(+)

Detailed changes

lib/electrum.rb 🔗

@@ -0,0 +1,80 @@
+# frozen_string_literal: true
+
+require "bigdecimal"
+require "em_promise"
+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 createnewaddress
+		rpc_call(:createnewaddress, {}).then { |r| r["result"] }
+	end
+
+	def getaddresshistory(address)
+		rpc_call(:getaddresshistory, address: address).then { |r| r["result"] }
+	end
+
+	def gettransaction(tx_hash)
+		rpc_call(:gettransaction, txid: tx_hash).then { |tx|
+			rpc_call(:deserialize, [tx["result"]])
+		}.then do |tx|
+			Transaction.new(self, tx_hash, tx["result"])
+		end
+	end
+
+	def get_tx_status(tx_hash)
+		rpc_call(:get_tx_status, txid: tx_hash).then { |r| r["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).then { |r| r["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)
+		post_json(
+			jsonrpc: "2.0",
+			id: SecureRandom.hex,
+			method: method.to_s,
+			params: params
+		).then { |res| JSON.parse(res.response) }
+	end
+
+	def post_json(data)
+		EM::HttpRequest.new(
+			@rpc_uri,
+			tls: { verify_peer: true }
+		).post(
+			head: {
+				"Authorization" => [@rpc_username, @rpc_password],
+				"Content-Type" => "application/json"
+			},
+			body: data.to_json
+		)
+	end
+end

test/test_electrum.rb 🔗

@@ -0,0 +1,106 @@
+# frozen_string_literal: true
+
+require "test_helper"
+require "electrum"
+
+class ElectrumTest < Minitest::Test
+	RPC_URI = "http://example.com"
+
+	def setup
+		@electrum = Electrum.new(
+			rpc_uri: RPC_URI,
+			rpc_username: "username",
+			rpc_password: "password"
+		)
+	end
+
+	def stub_rpc(method, params)
+		stub_request(:post, RPC_URI).with(
+			headers: { "Content-Type" => "application/json" },
+			basic_auth: ["username", "password"],
+			body: hash_including(
+				method: method,
+				params: params
+			)
+		)
+	end
+
+	property(:getaddresshistory) { string(:alnum) }
+	em :test_getaddresshistory
+	def getaddresshistory(address)
+		req =
+			stub_rpc("getaddresshistory", address: address)
+			.to_return(body: { result: "result" }.to_json)
+		assert_equal "result", @electrum.getaddresshistory(address).sync
+		assert_requested(req)
+	end
+
+	property(:get_tx_status) { string(:alnum) }
+	em :test_get_tx_status
+	def get_tx_status(tx_hash)
+		req =
+			stub_rpc("get_tx_status", txid: tx_hash)
+			.to_return(body: { result: "result" }.to_json)
+		assert_equal "result", @electrum.get_tx_status(tx_hash).sync
+		assert_requested(req)
+	end
+
+	property(:gettransaction) { [string(:alnum), string(:xdigit)] }
+	em :test_gettransaction
+	def gettransaction(tx_hash, dummy_tx)
+		req1 =
+			stub_rpc("gettransaction", txid: tx_hash)
+			.to_return(body: { result: dummy_tx }.to_json)
+		req2 =
+			stub_rpc("deserialize", [dummy_tx])
+			.to_return(body: { result: { outputs: [] } }.to_json)
+		assert_kind_of Electrum::Transaction, @electrum.gettransaction(tx_hash).sync
+		assert_requested(req1)
+		assert_requested(req2)
+	end
+
+	class TransactionTest < Minitest::Test
+		def transaction(outputs=[])
+			electrum_mock = Minitest::Mock.new("Electrum")
+			[
+				electrum_mock,
+				Electrum::Transaction.new(
+					electrum_mock,
+					"txhash",
+					"outputs" => outputs
+				)
+			]
+		end
+
+		def test_confirmations
+			electrum_mock, tx = transaction
+			electrum_mock.expect(
+				:get_tx_status,
+				EMPromise.resolve("confirmations" => 1234),
+				["txhash"]
+			)
+			assert_equal 1234, tx.confirmations.sync
+		end
+		em :test_confirmations
+
+		def test_amount_for_empty
+			_, tx = transaction
+			assert_equal 0, tx.amount_for
+		end
+
+		def test_amount_for_address_not_present
+			_, tx = transaction([{ "address" => "address", "value_sats" => 1 }])
+			assert_equal 0, tx.amount_for("other_address")
+		end
+
+		def test_amount_for_address_present
+			_, tx = transaction([{ "address" => "address", "value_sats" => 1 }])
+			assert_equal 0.00000001, tx.amount_for("address")
+		end
+
+		def test_amount_for_one_of_address_present
+			_, tx = transaction([{ "address" => "address", "value_sats" => 1 }])
+			assert_equal 0.00000001, tx.amount_for("boop", "address", "lol")
+		end
+	end
+end