Integrate SimpleSwap into alt top up

Stephen Paul Weber created

Allow choosing Done, Add Bitcoin, XMR, or ETH with the last two being SimpleSwap
swap addresses (and thus only one-time use, for safety, we don't know how long
they keep a record of swaps etc).

Change summary

config-schema.dhall              |  1 
config.dhall.sample              |  3 +
forms/alt_top_up.rb              | 20 +++++-----
forms/alt_top_up/btc.rb          |  5 ++
forms/alt_top_up/simpleswap.rb   | 15 +++++++
lib/add_bitcoin_address.rb       | 50 -------------------------
lib/alt_top_up_form.rb           | 61 ++++++++++++++++++++++++++++--
lib/command.rb                   | 12 +++--
lib/customer_finacials.rb        |  2 +
lib/simple_swap.rb               | 66 ++++++++++++++++++++++++++++++++++
sgx_jmp.rb                       |  7 +--
test/test_add_bitcoin_address.rb | 46 -----------------------
test/test_admin_command.rb       |  1 
test/test_alt_top_up_form.rb     | 32 +++++++++-------
14 files changed, 186 insertions(+), 135 deletions(-)

Detailed changes

config-schema.dhall 🔗

@@ -41,6 +41,7 @@
 , rev_ai_token : Text
 , server : { host : Text, port : Natural }
 , sgx : Text
+, simpleswap_api_key : Text
 , sip : { app : Text, realm : Text }
 , sip_host : Text
 , snikket_hosting_api : Text

config.dhall.sample 🔗

@@ -83,5 +83,6 @@ in
 	rev_ai_token = "",
 	upstream_domain = "example.net",
 	approved_domains = toMap { `example.com` = Some "customer_id" },
-	keepgo = Some { api_key = "", access_token = "" }
+	keepgo = Some { api_key = "", access_token = "" },
+	simpleswap_api_key = ""
 }

forms/alt_top_up.rb 🔗

@@ -15,15 +15,15 @@ render "alt_top_up/mailing_address"
 render "alt_top_up/interac" if @currency == :CAD
 render "alt_top_up/btc_addresses"
 
-add_btc_label = if !@btc_addresses || @btc_addresses.empty?
-	"You have no Bitcoin addresses, would you like to create one?"
-else
-	"Or, create a new Bitcoin address?"
-end
-
 field(
-	var: "add_btc_address",
-	label: add_btc_label,
-	type: "boolean",
-	value: false
+	var: "http://jabber.org/protocol/commands#actions",
+	label: "Action",
+	type: "list-single",
+	options: [
+		{ label: "Done", value: "complete" },
+		{ label: "Add new Bitcoin address to account", value: "BTC" },
+		{ label: "Get single-use Monero address", value: "XMR" },
+		{ label: "Get single-use Ethereum address", value: "ETH" }
+	],
+	value: "complete"
 )

forms/alt_top_up/btc.rb 🔗

@@ -0,0 +1,5 @@
+result!
+title "New Bitcoin Address"
+instructions "Your new address has been created"
+
+render "alt_top_up/btc_addresses"

forms/alt_top_up/simpleswap.rb 🔗

@@ -0,0 +1,15 @@
+result!
+title "Single-use Address"
+instructions "Your single-use address has been created"
+
+DESCRIPTION =
+	"You can make a payment of any amount to this address and " \
+	"it will be credited to your account at the current exchange rate " \
+	"once fully confirmed.".freeze
+
+field(
+	var: "addresses",
+	label: "Address",
+	description: DESCRIPTION,
+	value: @addresses
+)

lib/add_bitcoin_address.rb 🔗

@@ -1,50 +0,0 @@
-# frozen_string_literal: true
-
-class AddBitcoinAddress
-	def self.for(iq, alt_form, customer)
-		if alt_form.parse(iq.form)[:add_btc_address]
-			new(iq, customer)
-		else
-			DoNot.new(iq)
-		end
-	end
-
-	def initialize(iq, customer)
-		@reply = iq.reply
-		@reply.status = :completed
-		@customer = customer
-	end
-
-	def write
-		@customer.add_btc_address.then do |addr|
-			form.fields = [{
-				var: "btc_address",
-				type: "fixed",
-				label: "Bitcoin Address",
-				value: addr
-			}]
-			BLATHER << @reply
-		end
-	end
-
-protected
-
-	def form
-		form = @reply.form
-		form.type = :result
-		form.title = "New Bitcoin Address"
-		form.instructions = "Your new address has been created"
-		form
-	end
-
-	class DoNot
-		def initialize(iq)
-			@reply = iq.reply
-			@reply.status = :completed
-		end
-
-		def write
-			BLATHER << @reply
-		end
-	end
-end

lib/alt_top_up_form.rb 🔗

@@ -1,5 +1,7 @@
 # frozen_string_literal: true
 
+require_relative "simple_swap"
+
 class AltTopUpForm
 	def self.for(customer)
 		customer.btc_addresses.then do |addrs|
@@ -8,6 +10,7 @@ class AltTopUpForm
 	end
 
 	def initialize(customer, btc_addresses)
+		@customer = customer
 		@balance = customer.balance
 		@currency = customer.currency
 		@btc_addresses = btc_addresses
@@ -23,10 +26,58 @@ class AltTopUpForm
 	end
 
 	def parse(form)
-		{
-			add_btc_address: ["1", "true"].include?(
-				form.field("add_btc_address")&.value.to_s
-			)
-		}
+		action =
+			form.field("http://jabber.org/protocol/commands#actions")&.value.to_s
+		case action
+		when "BTC"
+			BitcoinAddress.new(@customer)
+		when /\A[A-Z]{3}\Z/
+			SimpleSwapAddress.new(@customer, action, @btc_addresses.first)
+		else
+			NoOp.new
+		end
+	end
+
+	class NoOp
+		def action(*); end
+	end
+
+	class BitcoinAddress
+		def initialize(customer)
+			@customer = customer
+		end
+
+		def action(reply)
+			@customer.add_btc_address.then do |addr|
+				reply.command << FormTemplate.render(
+					"alt_top_up/btc",
+					btc_addresses: [addr]
+				)
+			end
+		end
+	end
+
+	class SimpleSwapAddress
+		def initialize(customer, currency, btc_address, simple_swap: SimpleSwap.new)
+			@customer = customer
+			@currency = currency.downcase
+			@btc_address = btc_address
+			@simple_swap = simple_swap
+		end
+
+		def btc_address
+			@btc_address || @customer.add_btc_address
+		end
+
+		def action(reply)
+			EMPromise.resolve(btc_address).then { |btc|
+				@simple_swap.fetch_addr(@currency, btc)
+			}.then do |addr|
+				reply.command << FormTemplate.render(
+					"alt_top_up/simpleswap",
+					addresses: [addr]
+				)
+			end
+		end
 	end
 end

lib/command.rb 🔗

@@ -76,12 +76,14 @@ class Command
 		def finish(text=nil, type: :info, status: :completed)
 			reply = @iq.reply
 			reply.status = status
-			yield reply if block_given?
-			if text
-				reply.note_type = type
-				reply.note_text = text
+			EMPromise.resolve(block_given? ? yield(reply) : nil).then {
+				if text
+					reply.note_type = type
+					reply.note_text = text
+				end
+			}.then do
+				EMPromise.reject(FinalStanza.new(reply))
 			end
-			EMPromise.reject(FinalStanza.new(reply))
 		end
 
 		def customer

lib/customer_finacials.rb 🔗

@@ -1,5 +1,7 @@
 # frozen_string_literal: true
 
+require "bigdecimal"
+
 class CustomerFinancials
 	def initialize(customer_id)
 		@customer_id = customer_id

lib/simple_swap.rb 🔗

@@ -0,0 +1,66 @@
+# frozen_string_literal: true
+
+require "em-http/middleware/json_response"
+
+class SimpleSwap
+	def initialize(api_key: CONFIG[:simpleswap_api_key])
+		@api_key = api_key
+	end
+
+	def fetch_range(currency)
+		req(
+			:aget, "get_ranges",
+			query: {
+				fixed: "false",
+				currency_from: currency,
+				currency_to: "btc"
+			}
+		).then { |req|
+			{ min: req.response["min"]&.to_d || 0, max: req.response["max"]&.to_d }
+		}
+	end
+
+	def fetch_rate(currency)
+		req(
+			:aget, "get_estimated",
+			query: {
+				fixed: "false",
+				currency_from: currency,
+				currency_to: "btc",
+				amount: 1
+			}
+		).then { |req| req.response.to_d }
+	end
+
+	def fetch_addr(currency, target)
+		req(
+			:apost, "create_exchange",
+			body: {
+				fixed: false,
+				currency_from: currency,
+				currency_to: "btc",
+				amount: 10,
+				address_to: target
+			}.to_json
+		).then(&method(:parse))
+	end
+
+	def parse(req)
+		return req.response["address_from"] if req.response["address_from"]
+
+		raise req.response["message"]
+	end
+
+	def req(m, path, query: {}, body: nil)
+		EM::HttpRequest.new(
+			"https://api.simpleswap.io/#{path}", tls: { verify_peer: true }
+		).tap { |req| req.use(EM::Middleware::JSONResponse) }.public_send(
+			m,
+			head: {
+				"Accept" => "application/json", "Content-Type" => "application/json"
+			},
+			query: query.merge(api_key: @api_key),
+			body: body
+		)
+	end
+end

sgx_jmp.rb 🔗

@@ -69,7 +69,6 @@ COMMAND_MANAGER = SessionManager.new(
 require_relative "lib/polyfill"
 require_relative "lib/alt_top_up_form"
 require_relative "lib/admin_command"
-require_relative "lib/add_bitcoin_address"
 require_relative "lib/backend_sgx"
 require_relative "lib/bwmsgsv2_repo"
 require_relative "lib/bandwidth_iris_patch"
@@ -619,13 +618,13 @@ Command.new(
 	list_for: ->(customer:, **) { !!customer&.currency }
 ) {
 	Command.customer.then { |customer|
-		EMPromise.all([AltTopUpForm.for(customer), customer])
-	}.then do |(alt_form, customer)|
+		AltTopUpForm.for(customer)
+	}.then do |alt_form|
 		Command.reply { |reply|
 			reply.allowed_actions = [:complete]
 			reply.command << alt_form.form
 		}.then do |iq|
-			AddBitcoinAddress.for(iq, alt_form, customer).write
+			Command.finish { |reply| alt_form.parse(iq.form).action(reply) }
 		end
 	end
 }.register(self).then(&CommandList.method(:register))

test/test_add_bitcoin_address.rb 🔗

@@ -1,46 +0,0 @@
-# frozen_string_literal: true
-
-require "test_helper"
-require "alt_top_up_form"
-require "add_bitcoin_address"
-
-class AddBitcoinAddressTest < Minitest::Test
-	def test_for
-		iq = Blather::Stanza::Iq::Command.new
-		cust = customer
-		AddBitcoinAddress.for(iq, AltTopUpForm.new(cust, []), cust)
-	end
-
-	def test_for_add_bitcoin
-		iq = Blather::Stanza::Iq::Command.new
-		iq.form.fields = [{ var: "add_btc_address", value: "true" }]
-		cust = customer
-		AddBitcoinAddress.for(iq, AltTopUpForm.new(cust, []), cust)
-	end
-
-	def test_write
-		cust = Minitest::Mock.new
-		cust.expect(:add_btc_address, EMPromise.resolve("newaddress"))
-		iq = Blather::Stanza::Iq::Command.new
-		AddBitcoinAddress.new(iq, cust).write.sync
-		assert_mock cust
-	end
-	em :test_write
-
-	class DoNotTest < Minitest::Test
-		AddBitcoinAddress::DoNot::BLATHER = Minitest::Mock.new
-
-		def test_write
-			AddBitcoinAddress::DoNot::BLATHER.expect(
-				:<<,
-				EMPromise.resolve(nil)
-			) do |stanza|
-				assert_equal :completed, stanza.status
-			end
-			iq = Blather::Stanza::Iq::Command.new
-			AddBitcoinAddress::DoNot.new(iq).write.sync
-			assert_mock AddBitcoinAddress::DoNot::BLATHER
-		end
-		em :test_write
-	end
-end

test/test_admin_command.rb 🔗

@@ -1,5 +1,6 @@
 # frozen_string_literal: true
 
+require "test_helper"
 require "admin_command"
 
 BackendSgx::IQ_MANAGER = Minitest::Mock.new

test/test_alt_top_up_form.rb 🔗

@@ -62,32 +62,36 @@ class AltTopUpFormTest < Minitest::Test
 		)
 	end
 
-	def test_parse_true
+	def test_parse_complete
 		iq_form = Blather::Stanza::X.new
 		iq_form.fields = [
-			{ var: "add_btc_address", value: "true" }
+			{ var: "http://jabber.org/protocol/commands#actions", value: "complete" }
 		]
-		assert AltTopUpForm.new(customer, []).parse(iq_form)[:add_btc_address]
+		assert_kind_of(
+			AltTopUpForm::NoOp,
+			AltTopUpForm.new(customer, []).parse(iq_form)
+		)
 	end
 
-	def test_parse_1
+	def test_parse_btc
 		iq_form = Blather::Stanza::X.new
 		iq_form.fields = [
-			{ var: "add_btc_address", value: "1" }
+			{ var: "http://jabber.org/protocol/commands#actions", value: "BTC" }
 		]
-		assert AltTopUpForm.new(customer, []).parse(iq_form)[:add_btc_address]
+		assert_kind_of(
+			AltTopUpForm::BitcoinAddress,
+			AltTopUpForm.new(customer, []).parse(iq_form)
+		)
 	end
 
-	def test_parse_false
+	def test_parse_xmr
 		iq_form = Blather::Stanza::X.new
 		iq_form.fields = [
-			{ var: "add_btc_address", value: "false" }
+			{ var: "http://jabber.org/protocol/commands#actions", value: "XMR" }
 		]
-		refute AltTopUpForm.new(customer, []).parse(iq_form)[:add_btc_address]
-	end
-
-	def test_parse_not_presend
-		iq_form = Blather::Stanza::X.new
-		refute AltTopUpForm.new(customer, []).parse(iq_form)[:add_btc_address]
+		assert_kind_of(
+			AltTopUpForm::SimpleSwapAddress,
+			AltTopUpForm.new(customer, []).parse(iq_form)
+		)
 	end
 end