From 259a260a61a22ec123f86539655c6ad20254a92b Mon Sep 17 00:00:00 2001 From: Hakkyung Lee <17398672+hklee93@users.noreply.github.com> Date: Mon, 25 Sep 2023 22:51:33 +0900 Subject: [PATCH 01/13] Fix incorrect gem name in README (#12) --- README.md | 56 +++++++++++++++++++++++++++++++------------------------ 1 file changed, 32 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index a09d24d..09e4745 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,8 +19,9 @@ $ 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" @@ -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. - From a8a73cff9e6ea89b322b572e8eeacec06930d5c1 Mon Sep 17 00:00:00 2001 From: Hakkyung Lee <17398672+hklee93@users.noreply.github.com> Date: Fri, 13 Oct 2023 01:52:38 +0900 Subject: [PATCH 02/13] [Fix] Various Cleanup (#13) --- lib/errors.rb | 1 + lib/{pi_network.rb => pinetwork.rb} | 19 +++++++++---------- pinetwork.gemspec | 2 +- 3 files changed, 11 insertions(+), 11 deletions(-) rename lib/{pi_network.rb => pinetwork.rb} (93%) diff --git a/lib/errors.rb b/lib/errors.rb index 6670dc4..52f0378 100644 --- a/lib/errors.rb +++ b/lib/errors.rb @@ -26,6 +26,7 @@ def initialize(message, payment_id, txid) super(message) @payment_id = payment_id @txid = txid + end end end end \ No newline at end of file diff --git a/lib/pi_network.rb b/lib/pinetwork.rb similarity index 93% rename from lib/pi_network.rb rename to lib/pinetwork.rb index 398d732..43dbc93 100644 --- a/lib/pi_network.rb +++ b/lib/pinetwork.rb @@ -1,5 +1,4 @@ -gem_dir = Gem::Specification.find_by_name("pi_network").gem_dir -require "#{gem_dir}/lib/errors" +require_relative 'errors' class PiNetwork require 'faraday' @@ -61,7 +60,7 @@ def submit_payment(payment_id) if payment.nil? || payment["identifier"] != payment_id payment = get_payment(payment_id) - txid = payment["transaction"]["txid"] + txid = payment["transaction"]&.dig("txid") raise Errors::TxidAlreadyLinkedError.new("This payment already has a linked txid", payment_id, txid) if txid.present? end @@ -82,27 +81,27 @@ def submit_payment(payment_id) return txid end - def complete_payment(identifier, txid) + def complete_payment(payment_id, txid) body = {"txid": txid} response = Faraday.post( - base_url + "/v2/payments/#{identifier}/complete", + base_url + "/v2/payments/#{payment_id}/complete", body.to_json, http_headers ) - @open_payments.delete(identifier) + @open_payments.delete(payment_id) handle_http_response(response, "An unknown error occurred while completing the payment") end - def cancel_payment(identifier) + def cancel_payment(payment_id) response = Faraday.post( - base_url + "/v2/payments/#{identifier}/cancel", + base_url + "/v2/payments/#{payment_id}/cancel", {}.to_json, http_headers, ) - @open_payments.delete(identifier) + @open_payments.delete(payment_id) handle_http_response(response, "An unknown error occurred while cancelling the payment") end @@ -130,7 +129,7 @@ def http_headers 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 + error_message = JSON.parse(response.body).dig("error_message") rescue unknown_error_message raise Errors::APIRequestError.new(error_message, response.status, response.body) end diff --git a/pinetwork.gemspec b/pinetwork.gemspec index 221d1c1..6370f75 100644 --- a/pinetwork.gemspec +++ b/pinetwork.gemspec @@ -5,7 +5,7 @@ Gem::Specification.new do |s| s.description = "Pi Network backend library for Ruby-based webservers." s.authors = ["Pi Core Team"] s.email = "support@minepi.com" - s.files = ["lib/pi_network.rb"] + s.files = ["lib/pi_network.rb", "lib/errors.rb"] s.homepage = "https://github.com/pi-apps/pi-ruby" s.license = "PiOS" s.add_runtime_dependency "stellar-sdk", "~> 0.29.0" From cb5edb8ac9a8dc6ab4fe85bac2bfde556203dd9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20Schiltz?= Date: Thu, 12 Oct 2023 19:01:58 +0200 Subject: [PATCH 03/13] Expand blockchain host compatibility (#16) --- lib/pinetwork.rb | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/pinetwork.rb b/lib/pinetwork.rb index 43dbc93..377c993 100644 --- a/lib/pinetwork.rb +++ b/lib/pinetwork.rb @@ -16,6 +16,8 @@ def initialize(api_key, wallet_private_key, options = {}) @api_key = api_key @account = load_account(wallet_private_key) @base_url = options[:base_url] || "https://api.minepi.com" + @mainnet_host = options[:mainnet_host] || "api.mainnet.minepi.com" + @testnet_host = options[:testnet_host] || "api.testnet.minepi.com" @open_payments = {} end @@ -143,8 +145,9 @@ def handle_http_response(response, unknown_error_message = "An unknown error occ 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" + host = (network.starts_with? "Pi Network") ? @mainnet_host : @testnet_host + horizon = "https://#{host}" + client = Stellar::Client.new(host: host, horizon: horizon) Stellar::default_network = network From 19ebd1cd00bb2ee6f89d7fee034ff30c5c7bb4f0 Mon Sep 17 00:00:00 2001 From: aurelienshz Date: Thu, 12 Oct 2023 19:09:14 +0200 Subject: [PATCH 04/13] v0.1.3 --- pinetwork.gemspec | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pinetwork.gemspec b/pinetwork.gemspec index 6370f75..349ce1d 100644 --- a/pinetwork.gemspec +++ b/pinetwork.gemspec @@ -1,11 +1,11 @@ Gem::Specification.new do |s| s.name = "pinetwork" - s.version = "0.1.2" + s.version = "0.1.3" s.summary = "Pi Network Ruby" s.description = "Pi Network backend library for Ruby-based webservers." s.authors = ["Pi Core Team"] s.email = "support@minepi.com" - s.files = ["lib/pi_network.rb", "lib/errors.rb"] + s.files = ["lib/pinetwork.rb", "lib/errors.rb"] s.homepage = "https://github.com/pi-apps/pi-ruby" s.license = "PiOS" s.add_runtime_dependency "stellar-sdk", "~> 0.29.0" From d3d5e5dc8d3d180b69d4aa7e44a25a05f28ed65a Mon Sep 17 00:00:00 2001 From: Hakkyung Lee <17398672+hklee93@users.noreply.github.com> Date: Sat, 3 Feb 2024 03:47:49 +0900 Subject: [PATCH 05/13] [Fix] .submit_payment raises an error when tx fails (#17) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Aurélien Schiltz --- lib/errors.rb | 13 +++++++++++++ lib/pinetwork.rb | 9 +++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/lib/errors.rb b/lib/errors.rb index 52f0378..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 @@ -28,5 +30,16 @@ def initialize(message, payment_id, txid) @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/pinetwork.rb b/lib/pinetwork.rb index 377c993..f69626d 100644 --- a/lib/pinetwork.rb +++ b/lib/pinetwork.rb @@ -188,8 +188,13 @@ def build_a2u_transaction(transaction_data) def submit_transaction(transaction) envelope = transaction.to_envelope(self.account.keypair) - response = self.client.submit_transaction(tx_envelope: envelope) - txid = response._response.body["id"] + begin + response = self.client.submit_transaction(tx_envelope: envelope) + txid = response._response.body["id"] + rescue => error + result_codes = error.response&.dig(:body, "extras", "result_codes") + raise Errors::TxSubmissionError.new(result_codes&.dig("transaction"), result_codes&.dig("operations")) + end end def validate_payment_data!(data, options = {}) From 61a2d444be89e293f0a8e0e006370fb2130bea39 Mon Sep 17 00:00:00 2001 From: Hakkyung Lee <17398672+hklee93@users.noreply.github.com> Date: Sat, 3 Feb 2024 03:50:34 +0900 Subject: [PATCH 06/13] Increase Thread Safety (#18) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Aurélien Schiltz --- Rakefile | 9 +++++ lib/pinetwork.rb | 74 +++++++++++++++++++++++------------- pinetwork.gemspec | 7 +++- test/a2u_concurrency_test.rb | 35 +++++++++++++++++ 4 files changed, 97 insertions(+), 28 deletions(-) create mode 100644 Rakefile create mode 100644 test/a2u_concurrency_test.rb 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/pinetwork.rb b/lib/pinetwork.rb index f69626d..54f5290 100644 --- a/lib/pinetwork.rb +++ b/lib/pinetwork.rb @@ -11,15 +11,21 @@ class PiNetwork attr_reader :base_url attr_reader :from_address - def initialize(api_key, wallet_private_key, options = {}) + BASE_URL = "https://api.minepi.com".freeze + MAINNET_HOST = "api.mainnet.minepi.com".freeze + TESTNET_HOST = "api.testnet.minepi.com".freeze + + def initialize(api_key:, wallet_private_key:, faraday: Faraday.new, 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" - @mainnet_host = options[:mainnet_host] || "api.mainnet.minepi.com" - @testnet_host = options[:testnet_host] || "api.testnet.minepi.com" + @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) @@ -43,7 +49,7 @@ def create_payment(payment_data) payment: payment_data, } - response = Faraday.post( + response = @faraday.post( base_url + "/v2/payments", request_body.to_json, http_headers, @@ -52,35 +58,39 @@ def create_payment(payment_data) parsed_response = handle_http_response(response, "An unknown error occurred while creating a payment") identifier = parsed_response["identifier"] - @open_payments[identifier] = parsed_response + @open_payments_mutex.synchronize do + @open_payments[identifier] = parsed_response + end return identifier end def submit_payment(payment_id) - payment = @open_payments[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 + 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"] + set_horizon_client(payment["network"]) + @from_address = payment["from_address"] - transaction_data = { - amount: payment["amount"], - identifier: payment["identifier"], - recipient: payment["to_address"] - } + transaction_data = { + amount: payment["amount"], + identifier: payment["identifier"], + recipient: payment["to_address"] + } - transaction = build_a2u_transaction(transaction_data) - txid = submit_transaction(transaction) + transaction = build_a2u_transaction(transaction_data) + txid = submit_transaction(transaction) - @open_payments.delete(payment_id) + @open_payments.delete(payment_id) - return txid + return txid + end end def complete_payment(payment_id, txid) @@ -92,7 +102,10 @@ def complete_payment(payment_id, txid) http_headers ) - @open_payments.delete(payment_id) + @open_payments_mutex.synchronize do + @open_payments.delete(payment_id) + end + handle_http_response(response, "An unknown error occurred while completing the payment") end @@ -103,7 +116,10 @@ def cancel_payment(payment_id) http_headers, ) - @open_payments.delete(payment_id) + @open_payments_mutex.synchronize do + @open_payments.delete(payment_id) + end + handle_http_response(response, "An unknown error occurred while cancelling the payment") end @@ -131,7 +147,7 @@ def http_headers 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_error_message + error_message = extract_error_message(response.body, unknown_error_message) raise Errors::APIRequestError.new(error_message, response.status, response.body) end @@ -207,7 +223,11 @@ def validate_payment_data!(data, options = {}) 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 start with \"S\"") unless seed.upcase.start_with?("S") raise StandardError.new("Private Seed should be 56 characters") unless seed.length == 56 end + + def extract_error_message(response_body, default_message) + JSON.parse(response_body).dig("error_message") rescue default_message + end end \ No newline at end of file diff --git a/pinetwork.gemspec b/pinetwork.gemspec index 349ce1d..cbad7a4 100644 --- a/pinetwork.gemspec +++ b/pinetwork.gemspec @@ -5,7 +5,12 @@ Gem::Specification.new do |s| s.description = "Pi Network backend library for Ruby-based webservers." s.authors = ["Pi Core Team"] s.email = "support@minepi.com" - s.files = ["lib/pinetwork.rb", "lib/errors.rb"] + s.files = [ + "lib/pinetwork.rb", + "lib/errors.rb", + "test/a2u_concurrency_test.rb", + "Rakefile" + ] s.homepage = "https://github.com/pi-apps/pi-ruby" s.license = "PiOS" s.add_runtime_dependency "stellar-sdk", "~> 0.29.0" diff --git a/test/a2u_concurrency_test.rb b/test/a2u_concurrency_test.rb new file mode 100644 index 0000000..3f2ec1e --- /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 = "SC2L62EYF7LYF43L4OOSKUKDESRAFJZW3UW6RFZ57UY25VAMHTL2BFER" + + 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 \ No newline at end of file From 3db3a05ae7060e6451dfddc5d2745595bb0a8614 Mon Sep 17 00:00:00 2001 From: aurelienshz Date: Fri, 2 Feb 2024 19:53:10 +0100 Subject: [PATCH 07/13] v0.1.4 --- pinetwork.gemspec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pinetwork.gemspec b/pinetwork.gemspec index cbad7a4..204c82e 100644 --- a/pinetwork.gemspec +++ b/pinetwork.gemspec @@ -1,6 +1,6 @@ Gem::Specification.new do |s| s.name = "pinetwork" - s.version = "0.1.3" + s.version = "0.1.4" s.summary = "Pi Network Ruby" s.description = "Pi Network backend library for Ruby-based webservers." s.authors = ["Pi Core Team"] From d630884ea13bb891cd8dc0d1b202e3f8d6df12ae Mon Sep 17 00:00:00 2001 From: Hakkyung Lee <17398672+hklee93@users.noreply.github.com> Date: Thu, 4 Apr 2024 07:59:44 +0900 Subject: [PATCH 08/13] Update dependencies to support Ruby 3 and Rails 7 (#20) --- README.md | 2 +- lib/pinetwork.rb | 7 ++----- pinetwork.gemspec | 6 +++--- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 09e4745..afa378a 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ require 'pinetwork' 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 diff --git a/lib/pinetwork.rb b/lib/pinetwork.rb index 54f5290..57d987f 100644 --- a/lib/pinetwork.rb +++ b/lib/pinetwork.rb @@ -164,7 +164,7 @@ def set_horizon_client(network) host = (network.starts_with? "Pi Network") ? @mainnet_host : @testnet_host horizon = "https://#{host}" - client = Stellar::Client.new(host: host, horizon: horizon) + client = Stellar::Horizon::Client.new(host: host, horizon: horizon) Stellar::default_network = network @client = client @@ -185,10 +185,7 @@ def build_a2u_transaction(transaction_data) 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 - }) + 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 diff --git a/pinetwork.gemspec b/pinetwork.gemspec index 204c82e..64fa8d7 100644 --- a/pinetwork.gemspec +++ b/pinetwork.gemspec @@ -1,6 +1,6 @@ Gem::Specification.new do |s| s.name = "pinetwork" - s.version = "0.1.4" + s.version = "0.1.5" s.summary = "Pi Network Ruby" s.description = "Pi Network backend library for Ruby-based webservers." s.authors = ["Pi Core Team"] @@ -13,8 +13,8 @@ Gem::Specification.new do |s| ] 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.metadata = { "documentation_uri" => "https://github.com/pi-apps/pi-ruby", } From 82456a835c2fb20c1bc23d5cf1b3701c8b9a9ab5 Mon Sep 17 00:00:00 2001 From: tate1650 <62583210+tate1650@users.noreply.github.com> Date: Wed, 12 Mar 2025 04:08:55 -0700 Subject: [PATCH 09/13] Add more robust error handling for transaction submission (#28) * Add initial timebounds-retry implementation * Correct syntax mistake * Fix typo in string method name * Add general success test case * Correct bug with pseduo-infinite tx submission retries * Add test for user_error_response * Retry on 5xx, raise error on other non-success * Add more robust user and server error tests * Add tx submission timeout test * Adjust comment wording * replace hardcoded test key * update gemspec * Adjust timebounds variable naming Co-authored-by: Hakkyung Lee <17398672+hklee93@users.noreply.github.com> * Remove timeout Co-authored-by: Hakkyung Lee <17398672+hklee93@users.noreply.github.com> * Safer error code assignments for TxSubmissionError exception Co-authored-by: Hakkyung Lee <17398672+hklee93@users.noreply.github.com> * Remove redundant imports Co-authored-by: Hakkyung Lee <17398672+hklee93@users.noreply.github.com> * Randomly generate test wallet seed Co-authored-by: Hakkyung Lee <17398672+hklee93@users.noreply.github.com> * Randomly generate recipient wallet address Co-authored-by: Hakkyung Lee <17398672+hklee93@users.noreply.github.com> * Remove internal check for timeout (leaving it to Stellar SDK) * Remove timeout test since Stellar is handling it now * safe access --------- Co-authored-by: Tate Mauzy Co-authored-by: Hakkyung Lee <17398672+hklee93@users.noreply.github.com> --- lib/pinetwork.rb | 58 +++++++++++--- pinetwork.gemspec | 7 +- test/a2u_concurrency_test.rb | 8 +- test/transaction_submission_test.rb | 115 ++++++++++++++++++++++++++++ 4 files changed, 173 insertions(+), 15 deletions(-) create mode 100644 test/transaction_submission_test.rb diff --git a/lib/pinetwork.rb b/lib/pinetwork.rb index 57d987f..80f8a96 100644 --- a/lib/pinetwork.rb +++ b/lib/pinetwork.rb @@ -14,6 +14,8 @@ class PiNetwork 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_format!(wallet_private_key) @@ -56,7 +58,7 @@ def create_payment(payment_data) ) 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 @@ -161,7 +163,7 @@ def handle_http_response(response, unknown_error_message = "An unknown error occ end def set_horizon_client(network) - host = (network.starts_with? "Pi Network") ? @mainnet_host : @testnet_host + host = (network.start_with? "Pi Network") ? @mainnet_host : @testnet_host horizon = "https://#{host}" client = Stellar::Horizon::Client.new(host: host, horizon: horizon) @@ -176,7 +178,7 @@ def load_account(private_seed) 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]) @@ -185,18 +187,33 @@ def build_a2u_transaction(transaction_data) 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 + memo: memo, + time_bounds: time_bounds ) - transaction = transaction_builder.add_operation(payment_operation).set_timeout(180000).build + 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) @@ -204,9 +221,32 @@ def submit_transaction(transaction) begin response = self.client.submit_transaction(tx_envelope: envelope) txid = response._response.body["id"] + + return txid if txid.present? + + status = response._response.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) + raise Errors::TxSubmissionError.new(tx_error_code, op_error_code) + 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 - result_codes = error.response&.dig(:body, "extras", "result_codes") - raise Errors::TxSubmissionError.new(result_codes&.dig("transaction"), result_codes&.dig("operations")) + 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 @@ -227,4 +267,4 @@ def validate_private_seed_format!(seed) def extract_error_message(response_body, default_message) JSON.parse(response_body).dig("error_message") rescue default_message end -end \ No newline at end of file +end diff --git a/pinetwork.gemspec b/pinetwork.gemspec index 64fa8d7..c8a2bb3 100644 --- a/pinetwork.gemspec +++ b/pinetwork.gemspec @@ -1,20 +1,23 @@ Gem::Specification.new do |s| s.name = "pinetwork" - s.version = "0.1.5" + 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/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.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 index 3f2ec1e..a286240 100644 --- a/test/a2u_concurrency_test.rb +++ b/test/a2u_concurrency_test.rb @@ -5,10 +5,10 @@ class A2UConcurrencyTest < Minitest::Test def test_concurrent_create_payment total_threads = 10000 api_key = "api-key" - wallet_private_key = "SC2L62EYF7LYF43L4OOSKUKDESRAFJZW3UW6RFZ57UY25VAMHTL2BFER" + 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) @@ -22,7 +22,7 @@ def test_concurrent_create_payment 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 @@ -32,4 +32,4 @@ def test_concurrent_create_payment 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 \ No newline at end of file +end diff --git a/test/transaction_submission_test.rb b/test/transaction_submission_test.rb new file mode 100644 index 0000000..476342f --- /dev/null +++ b/test/transaction_submission_test.rb @@ -0,0 +1,115 @@ +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 = [ + { _response: { body: { title: "Historical DB Is Too Stale" }, status: 503 } }, # Server error response first + { _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 + } + } + ] + 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 + + 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 From 735129ace0c7f5ee42fe22782b5a014f4806bc6b Mon Sep 17 00:00:00 2001 From: Hakkyung Lee <17398672+hklee93@users.noreply.github.com> Date: Wed, 12 Mar 2025 20:10:59 +0900 Subject: [PATCH 10/13] [Fix] Using BigDecimal to avoid rounding issue (#23) * bump version * update validate_private_seed * use big decimal --- lib/pinetwork.rb | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/lib/pinetwork.rb b/lib/pinetwork.rb index 80f8a96..1a2d960 100644 --- a/lib/pinetwork.rb +++ b/lib/pinetwork.rb @@ -18,7 +18,8 @@ class PiNetwork TX_RETRY_DELAY_SECONDS = 5 def initialize(api_key:, wallet_private_key:, faraday: Faraday.new, options: {}) - validate_private_seed_format!(wallet_private_key) + validate_private_seed!(wallet_private_key) + @api_key = api_key @account = load_account(wallet_private_key) @base_url = options[:base_url] || BASE_URL @@ -81,7 +82,7 @@ def submit_payment(payment_id) @from_address = payment["from_address"] transaction_data = { - amount: payment["amount"], + amount: BigDecimal(payment["amount"].to_s), identifier: payment["identifier"], recipient: payment["to_address"] } @@ -259,9 +260,12 @@ def validate_payment_data!(data, options = {}) 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.start_with?("S") - raise StandardError.new("Private Seed should be 56 characters") unless seed.length == 56 + 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) From 13f151e45e05b5722e865ad429fa90b0982692dd Mon Sep 17 00:00:00 2001 From: Kamicio1234 Date: Fri, 25 Apr 2025 01:09:45 +0000 Subject: [PATCH 11/13] Initial commit From 2e50eaaa901d3b82956a7bc5d9d49bc358eb3d25 Mon Sep 17 00:00:00 2001 From: tate1650 <62583210+tate1650@users.noreply.github.com> Date: Mon, 28 Apr 2025 15:16:07 -0700 Subject: [PATCH 12/13] Correct horizon error response handling (#31) Co-authored-by: Tate Mauzy --- lib/pinetwork.rb | 2 +- test/transaction_submission_test.rb | 13 ++++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/lib/pinetwork.rb b/lib/pinetwork.rb index 1a2d960..09a105b 100644 --- a/lib/pinetwork.rb +++ b/lib/pinetwork.rb @@ -225,7 +225,7 @@ def submit_transaction(transaction) return txid if txid.present? - status = response._response.status + 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 diff --git a/test/transaction_submission_test.rb b/test/transaction_submission_test.rb index 476342f..f48d3fe 100644 --- a/test/transaction_submission_test.rb +++ b/test/transaction_submission_test.rb @@ -67,7 +67,9 @@ def teardown def test_server_error_response submit_transaction_responses = [ - { _response: { body: { title: "Historical DB Is Too Stale" }, status: 503 } }, # Server error response first + # 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) @@ -82,15 +84,16 @@ def test_user_error_response { _response: { body: { - title: "Transaction Failed", - extras: { + "title": "Transaction Failed", + "extras": { result_codes: { transaction: "tx_failed", operations: ["op_no_source_account"] } - } + }, + "status": 400 }, - status: 400 + status: 200 } } ] From b6d2bd20f9aa5e2c8a8aa50953b71472573c6747 Mon Sep 17 00:00:00 2001 From: tate1650 <62583210+tate1650@users.noreply.github.com> Date: Thu, 8 May 2025 16:20:54 -0700 Subject: [PATCH 13/13] Allow for automatic retry if tx_too_early (#32) Co-authored-by: Tate Mauzy --- lib/pinetwork.rb | 6 ++++-- test/transaction_submission_test.rb | 27 +++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/lib/pinetwork.rb b/lib/pinetwork.rb index 09a105b..cbd30a9 100644 --- a/lib/pinetwork.rb +++ b/lib/pinetwork.rb @@ -228,9 +228,11 @@ def submit_transaction(transaction) 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 + 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) - raise Errors::TxSubmissionError.new(tx_error_code, op_error_code) + + # ...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]) diff --git a/test/transaction_submission_test.rb b/test/transaction_submission_test.rb index f48d3fe..aec9c58 100644 --- a/test/transaction_submission_test.rb +++ b/test/transaction_submission_test.rb @@ -107,6 +107,33 @@ def test_user_error_response 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)