commit 1766e723247484ff801b488b7c56a8d5ed2f5afc Author: Peppinux <46856137+Peppinux@users.noreply.github.com> Date: Sat Aug 8 22:21:16 2020 +0200 Initial commit diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..34e8325 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Peppinux + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5ff86ed --- /dev/null +++ b/README.md @@ -0,0 +1,216 @@ +# DERO Merchant Ruby SDK +Library with bindings for the [DERO Merchant REST API](https://merchant.dero.io/docs) for accepting DERO payments on a Ruby backend. + +## Requirements +- A store registered on your [DERO Merchant Dashboard](https://merchant.dero.io/dashboard) to receive an API Key and a Secret Key, required to send requests to the API. +- A Ruby web server. + +## Installation +`gem install dero-merchant-ruby-sdk` + +## Usage +### Import +`require "deromerchant"` + +### Setup +```ruby +dm_client = DeroMerchant::Client.new( + "API_KEY_OF_YOUR_STORE_GOES_HERE", # REQUIRED + "SECRET_KEY_OF_YOUR_STORE_GOES_HERE", # REQUIRED + scheme: "https", # OPTIONAL. Default: https + host: "merchant.dero.io", # OPTIONAL. Default: merchant.dero.io + api_version: "v1" # OPTIONAL. Default: v1 +) + +begin + res = dm_client.ping() + puts(res) # {"ping"=>"pong"} +rescue DeroMerchant::APIError => api_err + # Error returned by the API. Probably invalid API Key. + puts(api_err) +rescue => exception + # Somethign went wrong while sending the request. + # The server is offline or bad scheme/host/api version were provided. + puts(exception) +end +``` + +### Create a Payment +```ruby +begin + # payment = dm_client.create_payment("USD", 1) // USD value will be converted to DERO + # payment = dm_client.create_payment("EUR", 100) // Same thing goes for EUR and other currencies supported by the CoinGecko API V3 + payment = dm_client.create_payment("DERO", 10) + + puts(payment) +=begin + Hash + { + "paymentID"=>"ba5a517df8506a9f55b24d18bb66d316d8df6ed93376c6414c1876d7421764b9", + "status"=>"pending", + "currency"=>"DERO", + "currencyAmount"=>10, + "exchangeRate"=>1, + "deroAmount"=>"10.000000000000", + "atomicDeroAmount"=>10000000000000, + "integratedAddress"=>"dETin8HwLs94N6j8zASZjD8htBbQTkhUuicZEYKBG6zQENd8mrhopv3YqaeP3Q9q1RMLHX3PvF4F4Xy1cN3Rndq7daiU7JSmXpBET9APnksErnJCXaBriPySALsG8JWrUt571tRDA4Q1Cb", + "creationTime"=>"2020-08-07T14:10:13.775959Z", + "ttl"=>60 + } +=end +rescue DeroMerchant::APIError => api_err + # Handle API Error +rescue => exception + # Handle exception +end +``` + +### Get a Payment from its ID +```ruby +begin + payment_id = "ba5a517df8506a9f55b24d18bb66d316d8df6ed93376c6414c1876d7421764b9" + payment = dm_client.get_payment(payment_id) + + puts(payment) +=begin + Hash + { + "paymentID"=>"ba5a517df8506a9f55b24d18bb66d316d8df6ed93376c6414c1876d7421764b9", + "status"=>"pending", + "currency"=>"DERO", + "currencyAmount"=>10, + "exchangeRate"=>1, + "deroAmount"=>"10.000000000000", + "atomicDeroAmount"=>10000000000000, + "integratedAddress"=>"dETin8HwLs94N6j8zASZjD8htBbQTkhUuicZEYKBG6zQENd8mrhopv3YqaeP3Q9q1RMLHX3PvF4F4Xy1cN3Rndq7daiU7JSmXpBET9APnksErnJCXaBriPySALsG8JWrUt571tRDA4Q1Cb", + "creationTime"=>"2020-08-07T14:10:13.775959Z", + "ttl"=>55 + } +=end +rescue DeroMerchant::APIError => api_err + # Handle API Error +rescue => exception + # Handle exception +end +``` + +### Get an array of Payments from their IDs +```ruby +begin + payment_ids = ["ba5a517df8506a9f55b24d18bb66d316d8df6ed93376c6414c1876d7421764b9", "95f28cb0a70a10f42e1e748d825cc72a110bae317205d6a4c1c74d8bf8927a24"] + payments = dm_client.get_payments(payment_ids) + + puts(payments) +=begin + Hashes + { + "paymentID"=>"ba5a517df8506a9f55b24d18bb66d316d8df6ed93376c6414c1876d7421764b9", + "status"=>"pending", + "currency"=>"DERO", + "currencyAmount"=>10, + "exchangeRate"=>1, + "deroAmount"=>"10.000000000000", + "atomicDeroAmount"=>10000000000000, + "integratedAddress"=>"dETin8HwLs94N6j8zASZjD8htBbQTkhUuicZEYKBG6zQENd8mrhopv3YqaeP3Q9q1RMLHX3PvF4F4Xy1cN3Rndq7daiU7JSmXpBET9APnksErnJCXaBriPySALsG8JWrUt571tRDA4Q1Cb", + "creationTime"=>"2020-08-07T14:10:13.775959Z", + "ttl"=>51 + } + { + "paymentID"=>"95f28cb0a70a10f42e1e748d825cc72a110bae317205d6a4c1c74d8bf8927a24", + "status"=>"pending", + "currency"=>"DERO", + "currencyAmount"=>10, + "exchangeRate"=>1, + "deroAmount"=>"10.000000000000", + "atomicDeroAmount"=>10000000000000, + "integratedAddress"=>"dETin8HwLs94N6j8zASZjD8htBbQTkhUuicZEYKBG6zQENd8mrhopv3YqaeP3Q9q1RMLHX3PvF4F4Xy1cN3Rndq7daiU3CDnpe22gezRV3eibbGX4drSePTPo1ye8wrH2c6b6YwysZLssQ", + "creationTime"=>"2020-08-07T14:14:38.441926Z", + "ttl"=>56 + } +=end +rescue DeroMerchant::APIError => api_err + # Handle API Error +rescue => exception + # Handle exception +end +``` + +### Get an array of filtered Payments +_Not detailed because this endpoint was created for an internal usecase._ +```ruby +begin + res = dm_client.get_filtered_payments({ + "limit" => int, + "page" => int, + "sort_by" => string, + "order_by" => string, + "filter_status" => string, + "filter_currency" => string + } + ) + + puts(res) # Hash +rescue DeroMerchant::APIError => api_err + # Handle API Error +rescue => exception + # Handle exception +end +``` + +### Get Pay helper page URL +```ruby +payment_id = "ba5a517df8506a9f55b24d18bb66d316d8df6ed93376c6414c1876d7421764b9" +pay_url = dm_client.get_pay_helper_url(payment_id) + +puts(pay_url) # https://merchant.dero.io/pay/ba5a517df8506a9f55b24d18bb66d316d8df6ed93376c6414c1876d7421764b9 +``` + +### Verify Webhook Signature +When using Webhooks to receive Payment status updates, it is highly suggested to verify the HTTP requests are actually sent by the DERO Merchant server thorugh the X-Signature header. + +**Example using Rails** + +app/controllers/webhooks_controller.rb +```ruby +require 'deromerchant' # Need to add deromerchant and httparty to Gemfile and run 'bundle install' before + +class WebhooksController < ApplicationController + skip_before_action :verify_authenticity_token + + WEBHOOK_SECRET_KEY = "WEBHOOK_SECRET_KEY_OF_YOUR_STORE_GOES_HERE" + + def dero_merchant_example + req_body = request.body.read() + req_signature = request.headers["X-Signature"] + valid = DeroMerchant::verify_webhook_signature(req_body, req_signature, WEBHOOK_SECRET_KEY) + + if valid + # Signature was verified. As such, as long Webhook Secret Key was stored securely, request should be trusted. + # Proceed with updating the status of the order on your store associated to req_json["paymentID"] accordingly to req_json["status"] + req_json = JSON.parse(req_body) + + puts req_json +=begin + Hash + { + "paymentID"=>"38ad8cf0c5da388fe9b5b44f6641619659c99df6cdece60c6e202acd78e895b1", + "status"=>"paid" + } +=end + else + # Signature of the body provided in the request does not match the signature of the body generated using webhook_secret_key. + # As such, REQUEST SHOULD NOT BE TRUSTED. + # This could also mean a wrong WEBHOOK_SECRET_KEY was provided as a param, so be extra careful when copying the value from the Dashboard. + end + end +end +``` + +config/routes.rb +```ruby +Rails.application.routes.draw do + # ... + post 'dero_merchant_webhook_example', to: 'webhooks#dero_merchant_example' + # ... +end +``` diff --git a/deromerchant.gemspec b/deromerchant.gemspec new file mode 100644 index 0000000..06fcbb8 --- /dev/null +++ b/deromerchant.gemspec @@ -0,0 +1,14 @@ +Gem::Specification.new do |s| + s.name = "deromerchant" + s.version = "1.0.0" + s.date = "2020-08-05" + s.summary = "Ruby SDK for DERO Merchant REST API" + s.description = "Library with bindings for the DERO Merchant REST API" + s.authors = ["Peppinux"] + s.files = `git ls-files`.split("\n") + s.homepage = "https://github.com/Peppinux/dero-merchant-ruby-sdk" + s.license = "MIT" + + s.required_ruby_version = ">= 2.5" + s.add_runtime_dependency("httparty", "~> 0.18", ">= 0.18.1") +end diff --git a/lib/deromerchant.rb b/lib/deromerchant.rb new file mode 100644 index 0000000..fb2b9a5 --- /dev/null +++ b/lib/deromerchant.rb @@ -0,0 +1,3 @@ +require_relative "./deromerchant/client.rb" +require_relative "./deromerchant/api_error.rb" +require_relative "./deromerchant/webhook.rb" diff --git a/lib/deromerchant/api_error.rb b/lib/deromerchant/api_error.rb new file mode 100644 index 0000000..251d2f4 --- /dev/null +++ b/lib/deromerchant/api_error.rb @@ -0,0 +1,16 @@ +module DeroMerchant + # Exception that represents the error object returned by the API. + class APIError < StandardError + # Error code returned by the API. + attr_reader :code + # Error message returned by the API. + attr_reader :message + + def initialize(code, message) + @code = code + @message = message + msg = "DeroMerchant Client: API Error: #{@code}: #{@message}" + super(msg) + end + end +end diff --git a/lib/deromerchant/client.rb b/lib/deromerchant/client.rb new file mode 100644 index 0000000..7fe34cc --- /dev/null +++ b/lib/deromerchant/client.rb @@ -0,0 +1,131 @@ +require "httparty" +require "net/http" +require "json" +require_relative "./crypto_util" +require_relative "./api_error" + +module DeroMerchant + # Dero Merchant Client. Has methods to interact with the Dero Merchant REST API. + class Client + DEFAULT_SCHEME = "https" + DEFAULT_HOST = "merchant.dero.io" + DEFAULT_API_VERSION = "v1" + + # Number of the seconds before a connection to the API times out. + attr_accessor :timeout + + def initialize(api_key, secret_key, scheme:DEFAULT_SCHEME, host:DEFAULT_HOST, api_version:DEFAULT_API_VERSION) + @scheme = scheme + @host = host + @api_version = api_version + @base_url = "#{@scheme}://#{@host}/api/#{@api_version}" + @timeout = 10 + + @api_key = api_key + @secret_key = secret_key + end + + # Sends a request to the API. + def send_request(method, endpoint, query_params: {}, payload: {}, sign_body: false) + url = @base_url + endpoint + headers = { + "User-Agent" => "DeroMerchant_Client_Ruby/1.0", + "X-API-Key" => @api_key + } + + res = nil + + if method == Net::HTTP::Get + res = HTTParty.get(url, :headers => headers, :query => query_params, timeout: @timeout) + elsif method == Net::HTTP::Post + headers["Content-Type"] = "application/json" + headers["Accept"] = "applciation/json" + + json_payload = JSON.generate(payload) + + if sign_body == true + signature = DeroMerchant::CryptoUtil::sign_message(json_payload, @secret_key) + headers["X-Signature"] = signature + end + + res = HTTParty.post(url, body: json_payload, :headers => headers, :query => query_params, timeout: @timeout) + else + raise ArgumentError.new("method must be Net::HTTP::Get or Net::HTTP::Post") + end + + res_json = nil + + begin + res_json = JSON.parse(res.body) + rescue + # Do nothing + end + + if res.code < 200 || res.code > 299 + if res_json != nil + raise DeroMerchant::APIError.new(res_json["error"]["code"], res_json["error"]["message"]) + else + if res.code == 404 + raise StandardError.new("DeroMerchant Client: error 404: page #{res.request.last_uri.to_s} not found") + else + raise StandardError.new("DeroMerchant Client: error #{res.status_code} returned by #{res.request.last_uri.to_s}") + end + end + else + return res_json + end + end + + # Pings the API. + def ping() + return self.send_request( + Net::HTTP::Get, + "/ping" + ) + end + + # Creates a new payment. + def create_payment(currency, amount) + return self.send_request( + Net::HTTP::Post, + "/payment", + payload: { + "currency" => currency, + "amount" => amount + }, + sign_body: true + ) + end + + # Gets a payment from its ID. + def get_payment(payment_id) + return self.send_request( + Net::HTTP::Get, + "/payment/#{payment_id}" + ) + end + + # Gets payments from their IDs. + def get_payments(payment_ids) + return self.send_request( + Net::HTTP::Post, + "/payments", + payload: payment_ids + ) + end + + # Gets filtered payments. + def get_filtered_payments(opts = {}) + return self.send_request( + Net::HTTP::Get, + "/payments", + query_params: opts + ) + end + + # Gets the URL of the Pay helper page of the payment ID. + def get_pay_helper_url(payment_id) + return "#{@scheme}://#{@host}/pay/#{payment_id}" + end + end +end diff --git a/lib/deromerchant/crypto_util.rb b/lib/deromerchant/crypto_util.rb new file mode 100644 index 0000000..a4893b7 --- /dev/null +++ b/lib/deromerchant/crypto_util.rb @@ -0,0 +1,47 @@ +require "openssl" + +module DeroMerchant + # Cryptography utility functions for generation/verification of HMACs. + module CryptoUtil + # Returns the SHA256 hex encoded signature of the message. + def self.sign_message(message, key) + return OpenSSL::HMAC.hexdigest("SHA256", [key].pack("H*"), message) + end + + # Returns whether the signature of the message is valid or not. + def self.valid_mac(message, message_mac, key) + signed_message = self.sign_message(message, key) + return self.secure_compare(message_mac, signed_message) + end + + # Credits for this function go to Rails' module: + # https://github.com/rails/rails/blob/fbe2433be6e052a1acac63c7faf287c52ed3c5ba/activesupport/lib/active_support/security_utils.rb + # released under MIT License. + # + # Constant time string comparison, for fixed length strings. + # + # The values compared should be of fixed length, such as strings + # that have already been processed by HMAC. Raises in case of length mismatch. + def self.fixed_length_secure_compare(a, b) + raise ArgumentError, "string length mismatch." unless a.bytesize == b.bytesize + + l = a.unpack "C#{a.bytesize}" + + res = 0 + b.each_byte { |byte| res |= byte ^ l.shift } + res == 0 + end + + # Credits for this function go to Rails' module: + # https://github.com/rails/rails/blob/fbe2433be6e052a1acac63c7faf287c52ed3c5ba/activesupport/lib/active_support/security_utils.rb + # released under MIT License. + # + # Constant time string comparison, for variable length strings. + # + # The values are first processed by SHA256, so that we don't leak length info + # via timing attacks. + def self.secure_compare(a, b) + fixed_length_secure_compare(::Digest::SHA256.digest(a), ::Digest::SHA256.digest(b)) && a == b + end + end +end diff --git a/lib/deromerchant/webhook.rb b/lib/deromerchant/webhook.rb new file mode 100644 index 0000000..887c373 --- /dev/null +++ b/lib/deromerchant/webhook.rb @@ -0,0 +1,8 @@ +require_relative "./crypto_util" + +module DeroMerchant + # Returns the validity of the signature of a webhook request. + def self.verify_webhook_signature(req_body, req_signature, webhook_secret_key) + return DeroMerchant::CryptoUtil::valid_mac(req_body, req_signature, webhook_secret_key) + end +end