~singpolyma/jmp-pay

42df6be0431a2b626637433a1748ea7a50f40644 — Stephen Paul Weber 16 days ago 8366442 + 6b4d0a8
Merge branch 'rubocop'

* rubocop:
  Update rubocop
M .rubocop.yml => .rubocop.yml +83 -63
@@ 1,113 1,133 @@
AllCops:
  TargetRubyVersion: 2.3
  TargetRubyVersion: 2.5
  NewCops: enable

Metrics/LineLength:
  Max: 80
Metrics/ClassLength:
  Exclude:
    - test/*

Metrics/MethodLength:
  Exclude:
    - test/*

Metrics/BlockLength:
  ExcludedMethods:
    - route
    - "on"
  Exclude:
    - test/*

Metrics/AbcSize:
  Exclude:
    - test/*

Naming/MethodParameterName:
  AllowNamesEndingInNumbers: false
  AllowedNames:
    - m
    - e
    - r
    - q
    - s
    - k
    - v
    - ex
    - tx
    - id
    - iq
    - ip
    - db

Layout/CaseIndentation:
  EnforcedStyle: end

Layout/Tab:
Layout/IndentationStyle:
  Enabled: false
  EnforcedStyle: tabs
  IndentationWidth: 4

Layout/IndentationWidth:
  Width: 1 # one tab

Lint/EndAlignment:
  EnforcedStyleAlignWith: variable
Layout/LineLength:
  Max: 80
  Exclude:
    - Gemfile

Lint/RescueException:
  Enabled: false
Layout/SpaceAroundEqualsInParameterDefault:
  EnforcedStyle: no_space

Style/AndOr:
  Enabled: false
Layout/AccessModifierIndentation:
  EnforcedStyle: outdent

Layout/AlignParameters:
  Enabled: false
Layout/FirstParameterIndentation:
  EnforcedStyle: consistent

Style/BlockDelimiters:
Style/AccessModifierDeclarations:
  Enabled: false

Layout/CaseIndentation:
  EnforcedStyle: end
Style/StringLiterals:
  EnforcedStyle: double_quotes

Style/Documentation:
Style/NumericLiterals:
  Enabled: false

Style/FormatString:
  EnforcedStyle: percent

Layout/LeadingCommentSpace:
  Enabled: false
Style/SymbolArray:
  EnforcedStyle: brackets

Layout/MultilineMethodCallBraceLayout:
  Enabled: false
Style/WordArray:
  EnforcedStyle: brackets

Layout/MultilineOperationIndentation:
Style/Documentation:
  Enabled: false

Style/MultilineTernaryOperator:
Style/DoubleNegation:
  EnforcedStyle: allowed_in_returns
  Enabled: false

Style/Next:
Style/PerlBackrefs:
  Enabled: false

Style/Not:
  Enabled: false
Style/SpecialGlobalVars:
  EnforcedStyle: use_perl_names

Style/NumericLiterals:
  MinDigits: 20
  Strict: true
Style/RegexpLiteral:
  EnforcedStyle: slashes
  AllowInnerSlashes: true

Style/NumericPredicate:
  Enabled: false
Lint/EndAlignment:
  EnforcedStyleAlignWith: variable

Layout/SpaceAroundOperators:
Lint/OutOfRangeRegexpRef:
  Enabled: false

Layout/SpaceInsideHashLiteralBraces:
  EnforcedStyle: no_space

Style/StringLiterals:
  EnforcedStyle: double_quotes

Style/NegatedIf:
Lint/MissingSuper:
  Enabled: false

Style/RedundantReturn:
  Enabled: false
Style/BlockDelimiters:
  EnforcedStyle: semantic
  AllowBracesOnProceduralOneLiners: true
  ProceduralMethods:
    - execute_command

Style/MultilineBlockChain:
  Enabled: false

Layout/SpaceAroundEqualsInParameterDefault:
  EnforcedStyle: no_space

Layout/IndentArray:
Layout/FirstArgumentIndentation:
  EnforcedStyle: consistent

Style/SymbolArray:
  EnforcedStyle: brackets

Layout/FirstParameterIndentation:
Layout/FirstArrayElementIndentation:
  EnforcedStyle: consistent

Style/Lambda:
  EnforcedStyle: lambda

Layout/AccessModifierIndentation:
  EnforcedStyle: outdent
Style/FormatString:
  EnforcedStyle: percent

Style/FormatStringToken:
  Enabled: false
  EnforcedStyle: unannotated

Style/WordArray:
  EnforcedStyle: brackets

Lint/UriEscapeUnescape:
  Enabled: false
Style/FrozenStringLiteralComment:
  Exclude:
    - forms/*

Style/RescueModifier:
Naming/AccessorMethodName:
  Enabled: false

M bin/active_tels_on_catapult => bin/active_tels_on_catapult +1 -0
@@ 37,6 37,7 @@ DB.exec(
).each do |row|
	cid = row["customer_id"]
	next if REDIS.exists?("catapult_cred-customer_#{cid}@jmp.chat")

	jid = REDIS.get("jmp_customer_jid-#{cid}")
	tel = REDIS.lindex("catapult_cred-#{jid}", 3)
	location = get_location(tel)

M bin/billing_monthly_cronjob => bin/billing_monthly_cronjob +21 -8
@@ 71,7 71,7 @@ end
stats = Stats.new(
	not_renewed: 0,
	renewed: 0,
	revenue: BigDecimal.new(0)
	revenue: BigDecimal(0)
)

class Plan


@@ 85,7 85,7 @@ class Plan
	end

	def price
		BigDecimal.new(@plan["monthly_price"].to_i) * 0.0001
		BigDecimal(@plan["monthly_price"].to_i) * 0.0001
	end

	def bill_customer(db, customer_id)


@@ 147,18 147,29 @@ class ExpiredCustomer

		def try_renew(_, stats)
			stats.add(:not_renewed, 1)
			if REDIS.exists?("jmp_customer_auto_top_up_amount-#{customer_id}") && \
			   @row["expires_at"] > LAST_WEEK
				@db.exec_params("SELECT pg_notify('low_balance', $1)", [customer_id])
			topup = "jmp_customer_auto_top_up_amount-#{customer_id}"
			if REDIS.exists?(topup) && @row["expires_at"] > LAST_WEEK
				@db.exec_params(
					"SELECT pg_notify('low_balance', $1)",
					[customer_id]
				)
			else
				return if REDIS.exists?("jmp_customer_low_balance-#{customer_id}")
				REDIS.set("jmp_customer_low_balance-#{customer_id}", Time.now, ex: ONE_WEEK)
				send_notification
				notify_if_needed
			end
		end

	protected

		def notify_if_needed
			return if REDIS.exists?("jmp_customer_low_balance-#{customer_id}")

			REDIS.set(
				"jmp_customer_low_balance-#{customer_id}",
				Time.now, ex: ONE_WEEK
			)
			send_notification
		end

		def jid
			REDIS.get("jmp_customer_jid-#{customer_id}")
		end


@@ 175,12 186,14 @@ class ExpiredCustomer

		def btc_addresses_for_notification
			return if btc_addresses.empty?

			"\nYou can buy credit by sending any amount of Bitcoin to one of "\
			"these addresses:\n#{btc_addresses.join("\n")}"
		end

		def send_notification
			raise "No JID for #{customer_id}, cannot notify" unless jid

			BlatherNotify.say(
				CONFIG[:notify_using][:target].call(jid),
				CONFIG[:notify_using][:body].call(

M bin/correct_duplicate_addrs => bin/correct_duplicate_addrs +5 -5
@@ 12,14 12,14 @@ redis = Redis.new

customer_id = ENV["DEFAULT_CUSTOMER_ID"]
unless customer_id
	puts "The env-var DEFAULT_CUSTOMER_ID must be set to the ID of the customer "\
		"who will receive the duplicated addrs, preferably a support customer or "\
		"something linked to notifications when stray money is sent to these "\
		"addresses"
	puts "The env-var DEFAULT_CUSTOMER_ID must be set to the ID " \
		"of the customer who will receive the duplicated addrs, preferably " \
		"a support customer or something linked to notifications when " \
		"stray money is sent to these addresses"
	exit 1
end

STDIN.each_line do |line|
$stdin.each_line do |line|
	match = line.match(/^(\w+) is used by the following \d+ keys: (.*)/)
	unless match
		puts "The following line can't be understood and is being ignored"

M bin/detect_duplicate_addrs => bin/detect_duplicate_addrs +2 -1
@@ 8,6 8,7 @@ redis = Redis.new

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(' ')}"
		puts "#{addr} is used by the following " \
		     "#{keys.length} keys: #{keys.join(' ')}"
	end
end

M bin/process_pending_btc_transactions => bin/process_pending_btc_transactions +18 -11
@@ 48,7 48,7 @@ unless (cad_to_usd = REDIS.get("cad_to_usd")&.to_f)
	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)
	REDIS.set("cad_to_usd", cad_to_usd, ex: 60 * 60)
end

canadianbitcoins = Nokogiri::HTML.parse(


@@ 59,7 59,7 @@ 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(
btc_sell_price[:CAD] = BigDecimal(
	bitcoin_row.at("td:nth-of-type(3)").text.match(/^\$(\d+\.\d+)/)[1]
)
btc_sell_price[:USD] = btc_sell_price[:CAD] * cad_to_usd


@@ 82,6 82,7 @@ class Plan

	def self.from_name(customer, plan_name, klass: Plan)
		return unless plan_name

		plan = CONFIG[:plans].find { |p| p[:name] == plan_name }
		klass.new(customer, plan) if plan
	end


@@ 100,7 101,8 @@ class Plan
	end

	def bonus_for(fiat_amount)
		return BigDecimal.new(0) if fiat_amount <= 15
		return BigDecimal(0) if fiat_amount <= 15

		fiat_amount * case fiat_amount
		when (15..29.99)
			0.01


@@ 112,7 114,7 @@ class Plan
	end

	def price
		BigDecimal.new(@plan[:monthly_price].to_i) * 0.0001
		BigDecimal(@plan[:monthly_price].to_i) * 0.0001
	end

	def insert(start:, expire:)


@@ 134,7 136,7 @@ class Plan
		end

		def activation_amount
			camnt = BigDecimal.new(CONFIG[:activation_amount].to_i) * 0.0001
			camnt = BigDecimal(CONFIG[:activation_amount].to_i) * 0.0001
			[camnt, price].max
		end



@@ 159,6 161,7 @@ class Plan
				-price,
				"Activate pending plan"
			)

			insert(start: Date.today, expire: @go_until)
			REDIS.del("pending_plan_for-#{@customer.id}")
			notify_approved


@@ 186,6 189,7 @@ class Customer
	def notify(body)
		jid = REDIS.get("jmp_customer_jid-#{@customer_id}")
		raise "No JID for #{customer_id}" unless jid

		BlatherNotify.say(
			CONFIG[:notify_using][:target].call(jid),
			CONFIG[:notify_using][:body].call(jid, body)


@@ 204,12 208,13 @@ class Customer
		result = DB.exec_params(<<-SQL, [@customer_id]).first&.[]("balance")
			SELECT balance FROM balances WHERE customer_id=$1
		SQL
		result || BigDecimal.new(0)
		result || BigDecimal(0)
	end

	def add_btc_credit(txid, btc_amount, fiat_amount)
		return unless add_transaction(txid, fiat_amount, "Bitcoin payment")
		if (bonus = plan.bonus_for(fiat_amount)) > 0

		if (bonus = plan.bonus_for(fiat_amount)).positive?
			add_transaction("bonus_for_#{txid}", bonus, "Bitcoin payment bonus")
		end
		notify_btc_credit(txid, btc_amount, fiat_amount, bonus)


@@ 220,13 225,14 @@ class Customer
		notify([
			"Your Bitcoin transaction of #{btc_amount.to_s('F')} BTC ",
			"has been added as $#{'%.4f' % fiat_amount} (#{plan.currency}) ",
			("+ $#{'%.4f' % bonus} bonus " if bonus > 0),
			("+ $#{'%.4f' % bonus} bonus " if bonus.positive?),
			"to your account.\n(txhash: #{tx_hash})"
		].compact.join)
	end

	def add_transaction(id, amount, note)
		DB.exec_params(<<-SQL, [@customer_id, id, amount, note]).cmd_tuples > 0
		args = [@customer_id, id, amount, note]
		DB.exec_params(<<-SQL, args).cmd_tuples.positive?
			INSERT INTO transactions
				(customer_id, transaction_id, amount, note)
			VALUES


@@ 236,10 242,11 @@ class Customer
	end
end

done = REDIS.hgetall("pending_btc_transactions").map do |(txid, customer_id)|
done = REDIS.hgetall("pending_btc_transactions").map { |(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
		# This is a send, not a receive, do not record it


@@ 258,6 265,6 @@ done = REDIS.hgetall("pending_btc_transactions").map do |(txid, customer_id)|
			warn "No plan for #{customer_id} cannot save #{txid}"
		end
	end
end
}

puts done.compact.join("\n")

M config.ru => config.ru +40 -45
@@ 4,6 4,7 @@ require "braintree"
require "date"
require "delegate"
require "dhall"
require "forwardable"
require "pg"
require "redis"
require "roda"


@@ 44,7 45,7 @@ class Plan
	end

	def price(months=1)
		(BigDecimal.new(@plan[:monthly_price].to_i) * months) / 10000
		(BigDecimal(@plan[:monthly_price].to_i) * months) / 10000
	end

	def currency


@@ 56,7 57,7 @@ class Plan
	end

	def self.active?(customer_id)
		DB.exec_params(<<~SQL, [customer_id]).first&.[]("count").to_i > 0
		DB.exec_params(<<~SQL, [customer_id]).first&.[]("count").to_i.positive?
			SELECT count(1) AS count FROM customer_plans
			WHERE customer_id=$1 AND expires_at > NOW()
		SQL


@@ 139,6 140,7 @@ class CreditCardGateway

		result = @gateway.customer.create
		raise "Braintree customer create failed" unless result.success?

		@customer_id = result.customer.id
		save_customer_id!
	end


@@ 183,6 185,7 @@ class CreditCardGateway

	def sale(ip:, **kwargs)
		return nil unless decline_guard(ip)

		tx = Transaction.sale(@gateway, **kwargs)
		return tx if tx



@@ 200,7 203,7 @@ class CreditCardGateway
			amount: plan.price(5),
			payment_method_nonce: nonce,
			merchant_account_id: plan.merchant_account,
			options: {submit_for_settlement: true}
			options: { submit_for_settlement: true }
		)&.insert && plan.bill_plan(@customer_id)
	end



@@ 219,15 222,18 @@ class UnknownTransactions
	def self.from(customer_id, address, tx_hashes)
		self.for(
			customer_id,
			fetch_rows_for(address, tx_hashes).map { |row| row["transaction_id"] }
			fetch_rows_for(address, tx_hashes).map { |row|
				row["transaction_id"]
			}
		)
	end

	def self.fetch_rows_for(address, tx_hashes)
		values = tx_hashes.map do |tx_hash|
		values = tx_hashes.map { |tx_hash|
			"('#{DB.escape_string(tx_hash)}/#{DB.escape_string(address)}')"
		end
		}
		return [] if values.empty?

		DB.exec_params(<<-SQL)
			SELECT transaction_id FROM
				(VALUES #{values.join(',')}) AS t(transaction_id)


@@ 257,24 263,25 @@ class UnknownTransactions
	end
end

# This class must contain all of the routes because of how the DSL works
# rubocop:disable Metrics/ClassLength
class JmpPay < Roda
	SENTRY_DSN = ENV["SENTRY_DSN"] && URI(ENV["SENTRY_DSN"])
	plugin :render, engine: "slim"
	plugin :common_logger, $stdout

	extend Forwardable
	def_delegators :request, :params

	def redis_key_btc_addresses
		"jmp_customer_btc_addresses-#{request.params['customer_id']}"
		"jmp_customer_btc_addresses-#{params['customer_id']}"
	end

	def verify_address_customer_id(r)
		return if REDIS.sismember(redis_key_btc_addresses, request.params["address"])
		return if REDIS.sismember(redis_key_btc_addresses, params["address"])

		warn "Address and customer_id do not match"
		r.halt([
			403,
			{"Content-Type" => "text/plain"},
			{ "Content-Type" => "text/plain" },
			"Address and customer_id do not match"
		])
	end


@@ 284,10 291,10 @@ class JmpPay < Roda
			verify_address_customer_id(r)

			UnknownTransactions.from(
				request.params["customer_id"],
				request.params["address"],
				params["customer_id"],
				params["address"],
				ELECTRUM
					.getaddresshistory(request.params["address"])
					.getaddresshistory(params["address"])
					.map { |item| item["tx_hash"] }
			).enqueue!



@@ 295,19 302,17 @@ class JmpPay < Roda
		end

		r.on :jid do |jid|
			Sentry.set_user(id: request.params["customer_id"], jid: jid)
			Sentry.set_user(id: params["customer_id"], jid: jid)

			gateway = CreditCardGateway.new(
				jid,
				request.params["customer_id"]
			)
			gateway = CreditCardGateway.new(jid, params["customer_id"])
			topup = "jmp_customer_auto_top_up_amount-#{gateway.customer_id}"

			r.on "activate" do
				Sentry.configure_scope do |scope|
					scope.set_transaction_name("activate")
					scope.set_context(
						"activate",
						plan_name: request.params["plan_name"]
						plan_name: params["plan_name"]
					)
				end



@@ 324,28 329,25 @@ class JmpPay < Roda

				r.get do
					if Plan.active?(gateway.customer_id)
						r.redirect request.params["return_to"], 303
						r.redirect params["return_to"], 303
					else
						render.call
					end
				end

				r.post do
					result = DB.transaction do
					result = DB.transaction {
						Plan.active?(gateway.customer_id) || gateway.buy_plan(
							request.params["plan_name"],
							request.params["braintree_nonce"],
							params["plan_name"],
							params["braintree_nonce"],
							request.ip
						)
					end
					if request.params["auto_top_up_amount"].to_i >= 15
						REDIS.set(
							"jmp_customer_auto_top_up_amount-#{gateway.customer_id}",
							request.params["auto_top_up_amount"].to_i
						)
					}
					if params["auto_top_up_amount"].to_i >= 15
						REDIS.set(topup, params["auto_top_up_amount"].to_i)
					end
					if result
						r.redirect request.params["return_to"], 303
						r.redirect params["return_to"], 303
					else
						render.call(error: true)
					end


@@ 359,24 361,18 @@ class JmpPay < Roda
						locals: {
							token: gateway.client_token,
							customer_id: gateway.customer_id,
							auto_top_up: REDIS.get(
								"jmp_customer_auto_top_up_amount-#{gateway.customer_id}"
							) || (gateway.payment_methods? ? "" : "15")
							auto_top_up: REDIS.get(topup) ||
							             (gateway.payment_methods? ? "" : "15")
						}
					)
				end

				r.post do
					gateway.default_payment_method = request.params["braintree_nonce"]
					if request.params["auto_top_up_amount"].to_i >= 15
						REDIS.set(
							"jmp_customer_auto_top_up_amount-#{gateway.customer_id}",
							request.params["auto_top_up_amount"].to_i
						)
					elsif request.params["auto_top_up_amount"].to_i == 0
						REDIS.del(
							"jmp_customer_auto_top_up_amount-#{gateway.customer_id}"
						)
					gateway.default_payment_method = params["braintree_nonce"]
					if params["auto_top_up_amount"].to_i >= 15
						REDIS.set(topup, params["auto_top_up_amount"].to_i)
					elsif params["auto_top_up_amount"].to_i.zero?
						REDIS.del(topup)
					end
					"OK"
				end


@@ 384,6 380,5 @@ class JmpPay < Roda
		end
	end
end
# rubocop:enable Metrics/ClassLength

run JmpPay.freeze.app

M lib/blather_notify.rb => lib/blather_notify.rb +2 -2
@@ 16,11 16,11 @@ module BlatherNotify

		EM.error_handler(&method(:panic))

		@thread = Thread.new do
		@thread = Thread.new {
			EM.run do
				client.run
			end
		end
		}

		Timeout.timeout(30) { @ready.pop }
		at_exit { wait_then_exit }

M lib/electrum.rb => lib/electrum.rb +11 -4
@@ 47,7 47,7 @@ class Electrum
		end

		def amount_for(*addresses)
			BigDecimal.new(
			BigDecimal(
				@tx["outputs"]
					.select { |o| addresses.include?(o["address"]) }
					.map { |o| o["value_sats"] }


@@ 67,16 67,23 @@ protected
		).body)
	end

	def post_json(data)
		req = Net::HTTP::Post.new(@rpc_uri, "Content-Type" => "application/json")
	def post_json_req(data)
		req = Net::HTTP::Post.new(
			@rpc_uri,
			"Content-Type" => "application/json"
		)
		req.basic_auth(@rpc_username, @rpc_password)
		req.body = data.to_json
		req
	end

	def post_json(data)
		Net::HTTP.start(
			@rpc_uri.hostname,
			@rpc_uri.port,
			use_ssl: @rpc_uri.scheme == "https"
		) do |http|
			http.request(req)
			http.request(post_json_req(data))
		end
	end
end

M lib/redis_addresses.rb => lib/redis_addresses.rb +8 -5
@@ 25,12 25,15 @@ end

module RedisBtcAddresses
	def self.each_user(redis)
		# I picked 1000 because it made a relatively trivial case take 15 seconds
		#   instead of forever.
		# 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|
		# 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|
			yield key, redis.smembers(key)
		end
	end

M lib/transaction.rb => lib/transaction.rb +6 -1
@@ 29,6 29,7 @@ class Transaction

	def bonus
		return BigDecimal(0) if amount <= 15

		amount *
			case amount
			when (15..29.99)


@@ 59,7 60,11 @@ protected

	def insert_bonus
		return if bonus <= 0
		params = [@customer_id, "bonus_for_#{@transaction_id}", @created_at, bonus]

		params = [
			@customer_id, "bonus_for_#{@transaction_id}",
			@created_at, bonus
		]
		DB.exec(<<~SQL, params)
			INSERT INTO transactions
				(customer_id, transaction_id, created_at, amount, note)

M test/test_electrum.rb => test/test_electrum.rb +9 -9
@@ 16,7 16,7 @@ class ElectrumTest < Minitest::Test

	def stub_rpc(method, params)
		stub_request(:post, RPC_URI).with(
			headers: {"Content-Type" => "application/json"},
			headers: { "Content-Type" => "application/json" },
			basic_auth: ["username", "password"],
			body: hash_including(
				method: method,


@@ 29,7 29,7 @@ class ElectrumTest < Minitest::Test
	def getaddresshistory(address)
		req =
			stub_rpc("getaddresshistory", address: address)
			.to_return(body: {result: "result"}.to_json)
			.to_return(body: { result: "result" }.to_json)
		assert_equal "result", @electrum.getaddresshistory(address)
		assert_requested(req)
	end


@@ 38,7 38,7 @@ class ElectrumTest < Minitest::Test
	def get_tx_status(tx_hash)
		req =
			stub_rpc("get_tx_status", txid: tx_hash)
			.to_return(body: {result: "result"}.to_json)
			.to_return(body: { result: "result" }.to_json)
		assert_equal "result", @electrum.get_tx_status(tx_hash)
		assert_requested(req)
	end


@@ 47,10 47,10 @@ class ElectrumTest < Minitest::Test
	def gettransaction(tx_hash, dummy_tx)
		req1 =
			stub_rpc("gettransaction", txid: tx_hash)
			.to_return(body: {result: dummy_tx}.to_json)
			.to_return(body: { result: dummy_tx }.to_json)
		req2 =
			stub_rpc("deserialize", [dummy_tx])
			.to_return(body: {result: {outputs: []}}.to_json)
			.to_return(body: { result: { outputs: [] } }.to_json)
		assert_kind_of Electrum::Transaction, @electrum.gettransaction(tx_hash)
		assert_requested(req1)
		assert_requested(req2)


@@ 73,7 73,7 @@ class ElectrumTest < Minitest::Test
			electrum_mock, tx = transaction
			electrum_mock.expect(
				:get_tx_status,
				{"confirmations" => 1234},
				{ "confirmations" => 1234 },
				["txhash"]
			)
			assert_equal 1234, tx.confirmations


@@ 85,17 85,17 @@ class ElectrumTest < Minitest::Test
		end

		def test_amount_for_address_not_present
			_, tx = transaction([{"address" => "address", "value_sats" => 1}])
			_, 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}])
			_, 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}])
			_, tx = transaction([{ "address" => "address", "value_sats" => 1 }])
			assert_equal 0.00000001, tx.amount_for("boop", "address", "lol")
		end
	end