diff --git a/README.md b/README.md index a09d24d..afa378a 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,13 @@ This is a official Pi Network Ruby gem to integrate the Pi Network apps platform ## Install 1. Add the following line to your Gemfile: + ```ruby -gem 'pi_network' +gem 'pinetwork' ``` 2. Install the gem + ```ruby $ bundle install ``` @@ -17,14 +19,15 @@ $ bundle install ## Example 1. Initialize the SDK + ```ruby -require 'pi_network' +require 'pinetwork' # DO NOT expose these values to public api_key = "YOUR_PI_API_KEY" wallet_private_seed = "S_YOUR_WALLET_PRIVATE_SEED" # starts with S -pi = PiNetwork.new(api_key, wallet_private_seed) +pi = PiNetwork.new(api_key: api_key, wallet_private_seed: wallet_private_seed) ``` 2. Create an A2U payment @@ -32,9 +35,9 @@ pi = PiNetwork.new(api_key, wallet_private_seed) Make sure to store your payment data in your database. Here's an example of how you could keep track of the data. Consider this a database table example. -| uid | product_id | amount | memo | payment_id | txid | -| :---: | :---: | :---: | :---: | :---: | :---: | -| `user_uid` | apple-pie-1 | 3.14 | Refund for apple pie | NULL | NULL | +| uid | product_id | amount | memo | payment_id | txid | +| :--------: | :---------: | :----: | :------------------: | :--------: | :--: | +| `user_uid` | apple-pie-1 | 3.14 | Refund for apple pie | NULL | NULL | ```ruby user_uid = "user_uid_of_your_app" @@ -45,7 +48,7 @@ payment_data = { "uid": user_uid } # It is critical that you store the payment_id in your database -# so that you don't double-pay the same user, by keeping track of the payment. +# so that you don't double-pay the same user, by keeping track of the payment. payment_id = pi.create_payment(payment_data) ``` @@ -53,11 +56,12 @@ payment_id = pi.create_payment(payment_data) After creating the payment, you'll get `payment_id`, which you should be storing in your database. -| uid | product_id | amount | memo | payment_id | txid | -| :---: | :---: | :---: | :---: | :---: | :---: | -| `user_uid` | apple-pie-1 | 3.14 | Refund for apple pie | `payment_id` | NULL | +| uid | product_id | amount | memo | payment_id | txid | +| :--------: | :---------: | :----: | :------------------: | :----------: | :--: | +| `user_uid` | apple-pie-1 | 3.14 | Refund for apple pie | `payment_id` | NULL | 4. Submit the payment to the Pi Blockchain + ```ruby # It is strongly recommended that you store the txid along with the payment_id you stored earlier for your reference. txid = pi.submit_payment(payment_id) @@ -67,43 +71,47 @@ txid = pi.submit_payment(payment_id) Similarly as you did in step 3, keep the txid along with other data. -| uid | product_id | amount | memo | payment_id | txid | -| :---: | :---: | :---: | :---: | :---: | :---: | -| `user_uid` | apple-pie-1 | 3.14 | Refund for apple pie | `payment_id` | `txid` | - +| uid | product_id | amount | memo | payment_id | txid | +| :--------: | :---------: | :----: | :------------------: | :----------: | :----: | +| `user_uid` | apple-pie-1 | 3.14 | Refund for apple pie | `payment_id` | `txid` | 6. Complete the payment + ```ruby payment = pi.complete_payment(payment_id, txid) ``` - ## Overall flow for A2U (App-to-User) payment To create an A2U payment using the Pi Ruby SDK, here's an overall flow you need to follow: 1. Initialize the SDK -> You'll be initializing the SDK with the Pi API Key of your app and the Private Seed of your app wallet. + + > You'll be initializing the SDK with the Pi API Key of your app and the Private Seed of your app wallet. 2. Create an A2U payment -> You can create an A2U payment using `create_payment` method. The method returns a payment identifier (payment id). + + > You can create an A2U payment using `create_payment` method. The method returns a payment identifier (payment id). 3. Store the payment id in your database -> It is critical that you store the payment id, returned by `create_payment` method, in your database so that you don't double-pay the same user, by keeping track of the payment. + + > It is critical that you store the payment id, returned by `create_payment` method, in your database so that you don't double-pay the same user, by keeping track of the payment. 4. Submit the payment to the Pi Blockchain -> You can submit the payment to the Pi Blockchain using `submit_payment` method. This method builds a payment transaction and submits it to the Pi Blockchain for you. Once submitted, the method returns a transaction identifier (txid). + + > You can submit the payment to the Pi Blockchain using `submit_payment` method. This method builds a payment transaction and submits it to the Pi Blockchain for you. Once submitted, the method returns a transaction identifier (txid). 5. Store the txid in your database -> It is strongly recommended that you store the txid along with the payment id you stored earlier for your reference. -6. Complete the payment -> After checking the transaciton with the txid you obtained, you must complete the payment, which you can do with `complete_payment` method. Upon completing, the method returns the payment object. Check the `status` field to make sure everything looks correct. + > It is strongly recommended that you store the txid along with the payment id you stored earlier for your reference. +6. Complete the payment + > After checking the transaciton with the txid you obtained, you must complete the payment, which you can do with `complete_payment` method. Upon completing, the method returns the payment object. Check the `status` field to make sure everything looks correct. ## SDK Reference This section shows you a list of available methods. + ### `create_payment` This method creates an A2U payment. @@ -111,6 +119,7 @@ This method creates an A2U payment. - Required parameter: `payment_data` You need to provide 4 different data and pass them as a single object to this method. + ```ruby payment_data = { "amount": number, # the amount of Pi you're paying to your user @@ -119,6 +128,7 @@ payment_data = { "uid": string # a user uid of your app. You should have access to this value if a user has authenticated on your app. } ``` + - Return value: `a payment identifier (payment_id)` ### `submit_payment` @@ -195,7 +205,7 @@ a new one." If a payment is returned by this method, you must follow one of the following 3 options: 1. cancel the payment, if it is not linked with a blockchain transaction -and you don't want to submit the transaction anymore + and you don't want to submit the transaction anymore 2. submit the transaction and complete the payment @@ -205,10 +215,8 @@ If you do not know what this payment maps to in your business logic, you may use which business logic item it relates to. Remember that `metadata` is a required argument when creating a payment, and should be used as a way to link this payment to an item of your business logic. - ## Troubleshooting ### Error when creating a payment: "You need to complete the ongoing payment first to create a new one." See documentation for the `get_incomplete_server_payments` above. - diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..770a6f0 --- /dev/null +++ b/Rakefile @@ -0,0 +1,9 @@ +require 'rake/testtask' + +Rake::TestTask.new do |t| + t.libs << "test" + t.test_files = FileList["test/*.rb"] +end + +desc "Run tests" +task default: :test \ No newline at end of file diff --git a/lib/errors.rb b/lib/errors.rb index 6670dc4..cdcb344 100644 --- a/lib/errors.rb +++ b/lib/errors.rb @@ -3,6 +3,7 @@ module Errors class APIRequestError < StandardError attr_reader :response_body attr_reader :response_status + def initialize(message, response_status, response_body) super(message) @response_status = response_status @@ -12,6 +13,7 @@ def initialize(message, response_status, response_body) class PaymentNotFoundError < StandardError attr_reader :payment_id + def initialize(message, payment_id) super(message) @payment_id = payment_id @@ -26,6 +28,18 @@ def initialize(message, payment_id, txid) super(message) @payment_id = payment_id @txid = txid + end + end + + class TxSubmissionError < StandardError + attr_reader :tx_error_code + attr_reader :op_error_codes + + def initialize(tx_error_code, op_error_codes) + super(message) + @tx_error_code = tx_error_code + @op_error_codes = op_error_codes + end end end end \ No newline at end of file diff --git a/lib/pi_network.rb b/lib/pi_network.rb deleted file mode 100644 index 398d732..0000000 --- a/lib/pi_network.rb +++ /dev/null @@ -1,206 +0,0 @@ -gem_dir = Gem::Specification.find_by_name("pi_network").gem_dir -require "#{gem_dir}/lib/errors" - -class PiNetwork - require 'faraday' - require 'json' - require 'stellar-sdk' - - attr_reader :api_key - attr_reader :client - attr_reader :account - attr_reader :base_url - attr_reader :from_address - - def initialize(api_key, wallet_private_key, options = {}) - validate_private_seed_format!(wallet_private_key) - @api_key = api_key - @account = load_account(wallet_private_key) - @base_url = options[:base_url] || "https://api.minepi.com" - - @open_payments = {} - end - - def get_payment(payment_id) - response = Faraday.get( - base_url + "/v2/payments/#{payment_id}", - {}, - http_headers, - ) - - if response.status == 404 - raise Errors::PaymentNotFoundError.new("Payment not found", payment_id) - end - - handle_http_response(response, "An unknown error occurred while fetching the payment") - end - - def create_payment(payment_data) - validate_payment_data!(payment_data, {amount: true, memo: true, metadata: true, uid: true}) - - request_body = { - payment: payment_data, - } - - response = Faraday.post( - base_url + "/v2/payments", - request_body.to_json, - http_headers, - ) - - parsed_response = handle_http_response(response, "An unknown error occurred while creating a payment") - - identifier = parsed_response["identifier"] - @open_payments[identifier] = parsed_response - - return identifier - end - - def submit_payment(payment_id) - payment = @open_payments[payment_id] - - if payment.nil? || payment["identifier"] != payment_id - payment = get_payment(payment_id) - txid = payment["transaction"]["txid"] - raise Errors::TxidAlreadyLinkedError.new("This payment already has a linked txid", payment_id, txid) if txid.present? - end - - set_horizon_client(payment["network"]) - @from_address = payment["from_address"] - - transaction_data = { - amount: payment["amount"], - identifier: payment["identifier"], - recipient: payment["to_address"] - } - - transaction = build_a2u_transaction(transaction_data) - txid = submit_transaction(transaction) - - @open_payments.delete(payment_id) - - return txid - end - - def complete_payment(identifier, txid) - body = {"txid": txid} - - response = Faraday.post( - base_url + "/v2/payments/#{identifier}/complete", - body.to_json, - http_headers - ) - - @open_payments.delete(identifier) - handle_http_response(response, "An unknown error occurred while completing the payment") - end - - def cancel_payment(identifier) - response = Faraday.post( - base_url + "/v2/payments/#{identifier}/cancel", - {}.to_json, - http_headers, - ) - - @open_payments.delete(identifier) - handle_http_response(response, "An unknown error occurred while cancelling the payment") - end - - def get_incomplete_server_payments - response = Faraday.get( - base_url + "/v2/payments/incomplete_server_payments", - {}, - http_headers, - ) - - res = handle_http_response(response, "An unknown error occurred while fetching incomplete payments") - res["incomplete_server_payments"] - end - - private - - def http_headers - return nil if @api_key.nil? - - { - "Authorization": "Key #{@api_key}", - "Content-Type": "application/json" - } - end - - def handle_http_response(response, unknown_error_message = "An unknown error occurred while making an API request") - unless response.status == 200 - error_message = JSON.parse(response.body).dig("error_message") rescue unknown_err_message - raise Errors::APIRequestError.new(error_message, response.status, response.body) - end - - begin - parsed_response = JSON.parse(response.body) - return parsed_response - rescue StandardError => err - error_message = "Failed to parse response body" - raise Errors::APIRequestError.new(error_message, response.status, response.body) - end - end - - def set_horizon_client(network) - host = network == "Pi Network" ? "api.mainnet.minepi.com" : "api.testnet.minepi.com" - horizon = network == "Pi Network" ? "https://api.mainnet.minepi.com" : "https://api.testnet.minepi.com" - client = Stellar::Client.new(host: host, horizon: horizon) - Stellar::default_network = network - - @client = client - end - - def load_account(private_seed) - account = Stellar::Account.from_seed(private_seed) - end - - def build_a2u_transaction(transaction_data) - raise StandardError.new("You should use a private seed of your app wallet!") if self.from_address != self.account.address - - validate_payment_data!(transaction_data, {amount: true, identifier: true, recipient: true}) - - amount = Stellar::Amount.new(transaction_data[:amount]) - # TODO: get this from horizon - fee = 100000 # 0.01π - recipient = Stellar::KeyPair.from_address(transaction_data[:recipient]) - memo = Stellar::Memo.new(:memo_text, transaction_data[:identifier]) - - payment_operation = Stellar::Operation.payment({ - destination: recipient, - amount: amount.to_payment - }) - - my_public_key = self.account.address - sequence_number = self.client.account_info(my_public_key).sequence.to_i - transaction_builder = Stellar::TransactionBuilder.new( - source_account: self.account.keypair, - sequence_number: sequence_number + 1, - base_fee: fee, - memo: memo - ) - - transaction = transaction_builder.add_operation(payment_operation).set_timeout(180000).build - end - - def submit_transaction(transaction) - envelope = transaction.to_envelope(self.account.keypair) - response = self.client.submit_transaction(tx_envelope: envelope) - txid = response._response.body["id"] - end - - def validate_payment_data!(data, options = {}) - raise ArgumentError.new("Missing amount") if options[:amount] && !data[:amount].present? - raise ArgumentError.new("Missing memo") if options[:memo] && !data[:memo].present? - raise ArgumentError.new("Missing metadata") if options[:metadata] && !data[:metadata].present? - raise ArgumentError.new("Missing uid") if options[:uid] && !data[:uid].present? - raise ArgumentError.new("Missing identifier") if options[:identifier] && !data[:identifier].present? - raise ArgumentError.new("Missing recipient") if options[:recipient] && !data[:recipient].present? - end - - def validate_private_seed_format!(seed) - raise StandardError.new("Private Seed should start with \"S\"") unless seed.upcase.starts_with?("S") - raise StandardError.new("Private Seed should be 56 characters") unless seed.length == 56 - end -end \ No newline at end of file diff --git a/lib/pinetwork.rb b/lib/pinetwork.rb new file mode 100644 index 0000000..cbd30a9 --- /dev/null +++ b/lib/pinetwork.rb @@ -0,0 +1,276 @@ +require_relative 'errors' + +class PiNetwork + require 'faraday' + require 'json' + require 'stellar-sdk' + + attr_reader :api_key + attr_reader :client + attr_reader :account + attr_reader :base_url + attr_reader :from_address + + BASE_URL = "https://api.minepi.com".freeze + MAINNET_HOST = "api.mainnet.minepi.com".freeze + TESTNET_HOST = "api.testnet.minepi.com".freeze + TX_SUBMISSION_TIMEOUT_SECONDS = 30 + TX_RETRY_DELAY_SECONDS = 5 + + def initialize(api_key:, wallet_private_key:, faraday: Faraday.new, options: {}) + validate_private_seed!(wallet_private_key) + + @api_key = api_key + @account = load_account(wallet_private_key) + @base_url = options[:base_url] || BASE_URL + @mainnet_host = options[:mainnet_host] || MAINNET_HOST + @testnet_host = options[:testnet_host] || TESTNET_HOST + @faraday = faraday + + @open_payments = {} + @open_payments_mutex = Mutex.new + end + + def get_payment(payment_id) + response = Faraday.get( + base_url + "/v2/payments/#{payment_id}", + {}, + http_headers, + ) + + if response.status == 404 + raise Errors::PaymentNotFoundError.new("Payment not found", payment_id) + end + + handle_http_response(response, "An unknown error occurred while fetching the payment") + end + + def create_payment(payment_data) + validate_payment_data!(payment_data, {amount: true, memo: true, metadata: true, uid: true}) + + request_body = { + payment: payment_data, + } + + response = @faraday.post( + base_url + "/v2/payments", + request_body.to_json, + http_headers, + ) + + parsed_response = handle_http_response(response, "An unknown error occurred while creating a payment") + + identifier = parsed_response["identifier"] + @open_payments_mutex.synchronize do + @open_payments[identifier] = parsed_response + end + + return identifier + end + + def submit_payment(payment_id) + @open_payments_mutex.synchronize do + payment = @open_payments[payment_id] + + if payment.nil? || payment["identifier"] != payment_id + payment = get_payment(payment_id) + txid = payment["transaction"]&.dig("txid") + raise Errors::TxidAlreadyLinkedError.new("This payment already has a linked txid", payment_id, txid) if txid.present? + end + + set_horizon_client(payment["network"]) + @from_address = payment["from_address"] + + transaction_data = { + amount: BigDecimal(payment["amount"].to_s), + identifier: payment["identifier"], + recipient: payment["to_address"] + } + + transaction = build_a2u_transaction(transaction_data) + txid = submit_transaction(transaction) + + @open_payments.delete(payment_id) + + return txid + end + end + + def complete_payment(payment_id, txid) + body = {"txid": txid} + + response = Faraday.post( + base_url + "/v2/payments/#{payment_id}/complete", + body.to_json, + http_headers + ) + + @open_payments_mutex.synchronize do + @open_payments.delete(payment_id) + end + + handle_http_response(response, "An unknown error occurred while completing the payment") + end + + def cancel_payment(payment_id) + response = Faraday.post( + base_url + "/v2/payments/#{payment_id}/cancel", + {}.to_json, + http_headers, + ) + + @open_payments_mutex.synchronize do + @open_payments.delete(payment_id) + end + + handle_http_response(response, "An unknown error occurred while cancelling the payment") + end + + def get_incomplete_server_payments + response = Faraday.get( + base_url + "/v2/payments/incomplete_server_payments", + {}, + http_headers, + ) + + res = handle_http_response(response, "An unknown error occurred while fetching incomplete payments") + res["incomplete_server_payments"] + end + + private + + def http_headers + return nil if @api_key.nil? + + { + "Authorization": "Key #{@api_key}", + "Content-Type": "application/json" + } + end + + def handle_http_response(response, unknown_error_message = "An unknown error occurred while making an API request") + unless response.status == 200 + error_message = extract_error_message(response.body, unknown_error_message) + raise Errors::APIRequestError.new(error_message, response.status, response.body) + end + + begin + parsed_response = JSON.parse(response.body) + return parsed_response + rescue StandardError => err + error_message = "Failed to parse response body" + raise Errors::APIRequestError.new(error_message, response.status, response.body) + end + end + + def set_horizon_client(network) + host = (network.start_with? "Pi Network") ? @mainnet_host : @testnet_host + horizon = "https://#{host}" + + client = Stellar::Horizon::Client.new(host: host, horizon: horizon) + Stellar::default_network = network + + @client = client + end + + def load_account(private_seed) + account = Stellar::Account.from_seed(private_seed) + end + + def build_a2u_transaction(transaction_data) + raise StandardError.new("You should use a private seed of your app wallet!") if self.from_address != self.account.address + + validate_payment_data!(transaction_data, {amount: true, identifier: true, recipient: true}) + + amount = Stellar::Amount.new(transaction_data[:amount]) + # TODO: get this from horizon + fee = 100000 # 0.01π + recipient = Stellar::KeyPair.from_address(transaction_data[:recipient]) + memo = Stellar::Memo.new(:memo_text, transaction_data[:identifier]) + + # Add time_bounds so we can place a time limit on the transaction and try the same + # one multiple times (in case of Horizon server errors) + min_time = Time.now.utc.to_i + max_time = min_time + TX_SUBMISSION_TIMEOUT_SECONDS + time_bounds = Stellar::TimeBounds.new(min_time:, max_time:) + + payment_operation = Stellar::Operation.payment(destination: recipient, amount: amount.to_payment) + + my_public_key = self.account.address + sequence_number = self.client.account_info(my_public_key).sequence.to_i + transaction_builder = Stellar::TransactionBuilder.new( + source_account: self.account.keypair, + sequence_number: sequence_number + 1, + base_fee: fee, + memo: memo, + time_bounds: time_bounds + ) + + transaction = transaction_builder.add_operation(payment_operation).build + end + + def parse_horizon_error_response(body) + result_codes = body&.dig("extras", "result_codes") + tx_error_code = result_codes&.dig("transaction") || "unknown" + op_error_code = result_codes&.dig("operations") || "unknown" + + return tx_error_code, op_error_code + end + + def submit_transaction(transaction) + envelope = transaction.to_envelope(self.account.keypair) + begin + response = self.client.submit_transaction(tx_envelope: envelope) + txid = response._response.body["id"] + + return txid if txid.present? + + status = response._response.body["status"] + error_type = status / 100 # 4 == client-side error; 5 == server-side error + + if error_type == 4 # Raise the error immediately; something is wrong on our end... + tx_error_code, op_error_code = parse_horizon_error_response(response._response.body) + + # ...UNLESS it's tx_too_early...then just wait and try again as if it were a server-side error + raise Errors::TxSubmissionError.new(tx_error_code, op_error_code) unless tx_error_code == "tx_too_early" + elsif error_type != 5 # Some unexpected_status_code + # Repurposing TxSubmissionError here so we don't have to make a new Error for an unlikely response to encounter + raise Errors::TxSubmissionError.new("unexpected_response_code", [status]) + end + + # Server-side error + # Wait a moment, then try the tx again + # If we're past the time bounds we'll receive a 400 response and raise an exception + sleep TX_RETRY_DELAY_SECONDS + + submit_transaction(transaction) + rescue Errors::TxSubmissionError => error + # No need to parse the response if we already formatted the exception in the `begin` block + raise error + rescue => error + tx_error_code, op_error_code = parse_horizon_error_response(error&.response&.dig(:body)) + raise Errors::TxSubmissionError.new(tx_error_code, op_error_code) + end + end + + def validate_payment_data!(data, options = {}) + raise ArgumentError.new("Missing amount") if options[:amount] && !data[:amount].present? + raise ArgumentError.new("Missing memo") if options[:memo] && !data[:memo].present? + raise ArgumentError.new("Missing metadata") if options[:metadata] && !data[:metadata].present? + raise ArgumentError.new("Missing uid") if options[:uid] && !data[:uid].present? + raise ArgumentError.new("Missing identifier") if options[:identifier] && !data[:identifier].present? + raise ArgumentError.new("Missing recipient") if options[:recipient] && !data[:recipient].present? + end + + def validate_private_seed!(seed) + begin + Stellar::Util::StrKey.check_decode(:seed, seed) + rescue StandardError + raise StandardError.new("Invalid Private Seed") + end + end + + def extract_error_message(response_body, default_message) + JSON.parse(response_body).dig("error_message") rescue default_message + end +end diff --git a/pinetwork.gemspec b/pinetwork.gemspec index 221d1c1..c8a2bb3 100644 --- a/pinetwork.gemspec +++ b/pinetwork.gemspec @@ -1,15 +1,23 @@ Gem::Specification.new do |s| s.name = "pinetwork" - s.version = "0.1.2" + s.version = "0.1.6" s.summary = "Pi Network Ruby" - s.description = "Pi Network backend library for Ruby-based webservers." + s.description = "Pi Network backend library for Ruby-based web servers." s.authors = ["Pi Core Team"] s.email = "support@minepi.com" - s.files = ["lib/pi_network.rb"] + s.files = [ + "lib/pinetwork.rb", + "lib/errors.rb", + "test/a2u_concurrency_test.rb", + "test/transaction_submission_test.rb", + "Rakefile" + ] s.homepage = "https://github.com/pi-apps/pi-ruby" s.license = "PiOS" - s.add_runtime_dependency "stellar-sdk", "~> 0.29.0" - s.add_runtime_dependency "faraday", "~> 0" + s.add_runtime_dependency "stellar-sdk", "~> 0.31.0" + s.add_runtime_dependency "faraday", "~> 1.6.0" + s.add_development_dependency "minitest", "~> 5.25.4" + s.add_development_dependency "mocha", "~> 2.7.1" s.metadata = { "documentation_uri" => "https://github.com/pi-apps/pi-ruby", } diff --git a/test/a2u_concurrency_test.rb b/test/a2u_concurrency_test.rb new file mode 100644 index 0000000..a286240 --- /dev/null +++ b/test/a2u_concurrency_test.rb @@ -0,0 +1,35 @@ +require 'minitest/autorun' +require_relative '../lib/pinetwork' + +class A2UConcurrencyTest < Minitest::Test + def test_concurrent_create_payment + total_threads = 10000 + api_key = "api-key" + wallet_private_key = Stellar::KeyPair.random.seed + + threads = [] + + faraday_stub = Minitest::Mock.new + pi = PiNetwork.new(api_key: api_key, wallet_private_key: wallet_private_key, faraday: faraday_stub) + + total_threads.times do + threads << Thread.new do + faraday_response = Faraday::Response.new( + status: 200, + body: {identifier: SecureRandom.alphanumeric(12)}.to_json, + response_headers: {} + ) + faraday_stub.expect(:post, faraday_response) do |url| + url == "https://api.minepi.com/v2/payments" + end + + payment_data = { amount: 1, memo: "test", metadata: {"info": "test"}, uid: "test-uid" } + payment_id = pi.create_payment(payment_data) + end + end + threads.each(&:join) + + open_payments_after = pi.instance_variable_get(:@open_payments) + assert_equal(total_threads, open_payments_after.values.uniq.count, "open_payments got corrupted!") + end +end diff --git a/test/transaction_submission_test.rb b/test/transaction_submission_test.rb new file mode 100644 index 0000000..aec9c58 --- /dev/null +++ b/test/transaction_submission_test.rb @@ -0,0 +1,145 @@ +require 'minitest/autorun' +require 'mocha/minitest' +require 'ostruct' +require_relative '../lib/pinetwork' +require_relative '../lib/errors' + +def set_tx_submission_timers(timeout, retry_delay) + # Disable warnings that will clog up the terminal + $VERBOSE = nil + + PiNetwork.const_set(:TX_SUBMISSION_TIMEOUT_SECONDS, timeout) + PiNetwork.const_set(:TX_RETRY_DELAY_SECONDS, retry_delay) + + $VERBOSE = true +end + +def json_parse_to_struct(hash) + JSON.parse(hash.to_json, { object_class: OpenStruct }) +end + +def setup_horizon_mock(submit_transaction_response_hashes) + horizon_mock = mock() + + account_info_response = OpenStruct.new(sequence: 1) # Used during build_a2u_transaction + horizon_mock.expects(:account_info).returns(account_info_response).at_least_once + + submit_transaction_responses = submit_transaction_response_hashes.map { |h| json_parse_to_struct(h) } + horizon_mock.expects(:submit_transaction).returns(*submit_transaction_responses).at_least_once + + horizon_mock +end + +class TransactionSubmissionTest < Minitest::Test + attr_reader :pi, :account, :payment, :txid + + def setup + # Adjust submission timeout values to speed the test up + set_tx_submission_timers(5, 1) + + # Then set up the necessary data + api_key = "api-key" + wallet_private_key = Stellar::KeyPair.random.seed + @pi = PiNetwork.new(api_key: api_key, wallet_private_key: wallet_private_key) + + from_wallet_keypair = Stellar::KeyPair.from_seed(wallet_private_key) + from_address = from_wallet_keypair.public_key + @account = OpenStruct.new(address: from_address, keypair: from_wallet_keypair) + + payment_id = "1234abcd" + @payment = { + "identifier" => payment_id, + "network" => "Pi Network", + "amount" => 3.14, + "from_address" => account.address, + "to_address" => Stellar::KeyPair.random.address + } + + @txid = "01234abcde" + + pi.stubs(:get_payment).returns(payment) # Avoid API call to platform BE + pi.stubs(:account).returns(account) + end + + def teardown + set_tx_submission_timers(30, 5) + end + + def test_server_error_response + submit_transaction_responses = [ + # Server error response first; for some reason Horizon always responds with 200 and only puts + # the true error code in the body + { _response: { body: { "title": "Historical DB Is Too Stale", "status": 503 }, status: 200 } }, + { _response: { body: { "id": txid }, status: 200 } } # Then success on retry + ] + horizon_mock = setup_horizon_mock(submit_transaction_responses) + + pi.stubs(:client).returns(horizon_mock) + + assert_equal(txid, pi.submit_payment(payment["identifier"]), "Returned txid does not match expected value") + end + + def test_user_error_response + submit_transaction_responses = [ + { + _response: { + body: { + "title": "Transaction Failed", + "extras": { + result_codes: { + transaction: "tx_failed", + operations: ["op_no_source_account"] + } + }, + "status": 400 + }, + status: 200 + } + } + ] + horizon_mock = setup_horizon_mock(submit_transaction_responses) + + pi.stubs(:client).returns(horizon_mock) + + error = assert_raises(PiNetwork::Errors::TxSubmissionError) { pi.submit_payment(payment["identifier"]) } + + assert_equal("tx_failed", error.tx_error_code, "Raised error has wrong tx error code") + assert_equal(["op_no_source_account"], error.op_error_codes, "Raised error has wrong op error codes") + end + + # tx_too_early is a special case of a user-side error triggered by some asynchrony between the clock + # on the app servers and the horizon servers. Just give it a moment and try again. + def test_tx_too_early_response + submit_transaction_responses = [ + { + _response: { + body: { + "title": "Transaction Failed", + "status": 400, + "extras": { + "result_codes": { + "transaction": "tx_too_early" + } + } + }, + status: 200 + } + }, + { _response: { body: { "id": txid }, status: 200 } } # Then success on retry + ] + horizon_mock = setup_horizon_mock(submit_transaction_responses) + + pi.stubs(:client).returns(horizon_mock) + + assert_equal(txid, pi.submit_payment(payment["identifier"]), "Returned txid does not match expected value") + end + + def test_success_response + submit_transaction_responses = [{ _response: { body: { "id": txid }, status: 200 } }] + horizon_mock = setup_horizon_mock(submit_transaction_responses) + + pi.stubs(:client).returns(horizon_mock) + + assert_equal(txid, pi.submit_payment(payment["identifier"]), "Returned txid does not match expected value") + end +end