diff --git a/Gemfile b/Gemfile index 53b5cbc..d06a793 100644 --- a/Gemfile +++ b/Gemfile @@ -23,7 +23,7 @@ gem 'devise_token_auth' gem 'discard', '>= 1.0.0' gem 'faker', '>= 1.9.1', require: false gem 'graphiql-rails', '>= 1.5.0' -gem 'graphql', '1.9.2' +gem 'graphql', '1.9.5' gem 'graphql-guard', '~> 1.2.1' gem 'kaminari', '>= 1.1.1' gem 'loofah', '>= 2.2.3' diff --git a/Gemfile.lock b/Gemfile.lock index c8605e4..2ee55e2 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -148,7 +148,7 @@ GEM graphiql-rails (1.7.0) railties sprockets-rails - graphql (1.9.2) + graphql (1.9.5) graphql-guard (1.2.2) graphql (>= 1.6.0, < 2) hashdiff (0.4.0) @@ -351,7 +351,7 @@ DEPENDENCIES factory_bot_rails (>= 4.0.0) faker (>= 1.9.1) graphiql-rails (>= 1.5.0) - graphql (= 1.9.2) + graphql (= 1.9.5) graphql-guard (~> 1.2.1) kaminari (>= 1.1.1) listen (>= 3.0.5, < 3.2) @@ -380,4 +380,4 @@ RUBY VERSION ruby 2.6.0p0 BUNDLED WITH - 1.17.2 + 1.17.3 diff --git a/app/graphql/mutations/resend_transaction_mutation.rb b/app/graphql/mutations/resend_transaction_mutation.rb new file mode 100644 index 0000000..fb56419 --- /dev/null +++ b/app/graphql/mutations/resend_transaction_mutation.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module Mutations + class ResendTransactionMutation < Types::Base::BaseMutation + description 'Given an old transaction, resend it with new parameters or gas prices' + + argument :id, ID, + required: true, + description: 'ID of the transaction to be resent' + argument :transaction_hash, String, + required: true, + description: 'Transaction hash' + argument :transaction_object, Types::Scalar::JSONObject, + required: true, + description: 'The JSONified transaction data object' + argument :signed_transaction, String, + required: true, + description: 'Signed transaction in HEX format' + + field :watched_transaction, Types::WatchedTransaction::WatchedTransactionType, + null: true, + description: 'Newly created transaction' + field :errors, [UserErrorType], + null: false, + description: <<~EOS + Mutation errors + + Operation Errors: + - Previous transaction not found + - Unauthorized action + - Nonce is not the same as the previous + EOS + + def resolve(id:, transaction_hash:, transaction_object:, signed_transaction:) + key = :watched_transaction + + unless (old = WatchingTransaction.find_by(id: id)) + return form_error(key, 'transaction_object', 'Previous transaction not found') + end + + attrs = { + txhash: transaction_hash, + transaction_object: transaction_object, + signed_transaction: signed_transaction + } + + result, tx_or_errors = WatchingTransaction.resend( + context.fetch(:current_user, nil), + old, + attrs + ) + + case result + when :unauthorized_action + form_error(key, 'id', 'Unauthorized action') + when :invalid_nonce + form_error(key, 'transaction_object', 'Nonce is not the same as the previous') + when :invalid_data + model_errors(key, tx_or_errors) + when :ok + model_result(key, tx_or_errors) + end + end + + def self.authorized?(object, context) + super && context.fetch(:current_user, nil) + end + end +end diff --git a/app/graphql/mutations/watch_transaction_mutation.rb b/app/graphql/mutations/watch_transaction_mutation.rb new file mode 100644 index 0000000..d02103e --- /dev/null +++ b/app/graphql/mutations/watch_transaction_mutation.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module Mutations + class WatchTransactionMutation < Types::Base::BaseMutation + description 'Given a transaction, save it to the database to be resent' + + argument :transaction_hash, String, + required: true, + description: 'Transaction hash' + argument :transaction_object, Types::Scalar::JSONObject, + required: true, + description: 'The JSONified transaction data object' + argument :signed_transaction, String, + required: true, + description: 'Signed transaction in HEX format' + + field :watched_transaction, Types::WatchedTransaction::WatchedTransactionType, + null: true, + description: 'Newly created transaction' + field :errors, [UserErrorType], + null: false, + description: <<~EOS + Mutation errors + + Operation Errors: + - Invalid transaction object + EOS + + def resolve(transaction_hash:, transaction_object:, signed_transaction:) + key = :watched_transaction + + attrs = { + txhash: transaction_hash, + transaction_object: transaction_object, + signed_transaction: signed_transaction + } + + result, tx_or_errors = WatchingTransaction.watch( + context.fetch(:current_user), + attrs + ) + + case result + when :invalid_data + model_errors(key, tx_or_errors) + when :ok + model_result(key, tx_or_errors) + end + end + + def self.authorized?(object, context) + super && context.fetch(:current_user, nil) + end + end +end diff --git a/app/graphql/resolvers/watched_transaction_resolver.rb b/app/graphql/resolvers/watched_transaction_resolver.rb new file mode 100644 index 0000000..bd925b0 --- /dev/null +++ b/app/graphql/resolvers/watched_transaction_resolver.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Resolvers + class WatchedTransactionResolver < Resolvers::Base + type Types::WatchedTransaction::WatchedTransactionType, null: true + + argument :txhash, String, + required: true, + description: 'Find the last watched transaction in the group with that txhash' + + def resolve(txhash:) + unless (tx = WatchingTransaction.find_by(txhash: txhash)) + return nil + end + + WatchingTransaction.where(group_id: tx.group_id).order('created_at ASC').last + end + + def self.authorized?(object, context) + super && context.fetch(:current_user, nil) + end + end +end diff --git a/app/graphql/schema.graphql b/app/graphql/schema.graphql index 1c4fc00..60a664c 100644 --- a/app/graphql/schema.graphql +++ b/app/graphql/schema.graphql @@ -792,6 +792,16 @@ Industry for KYC submission """ scalar IndustryValue +""" +Represents untyped JSON +""" +scalar JSON + +""" +The JSONified data object +""" +scalar JSONObject + """ A customer's KYC submission """ @@ -1135,6 +1145,11 @@ type Mutation { """ rejectKyc(input: RejectKycMutationInput!): RejectKycMutationPayload + """ + Given an old transaction, resend it with new parameters or gas prices + """ + resendTransaction(input: ResendTransactionMutationInput!): ResendTransactionMutationPayload + """ As the current user, submit a KYC to access more features of the app. @@ -1171,6 +1186,11 @@ type Mutation { Can only unpost a comment you posted. """ unpostComment(input: UnpostCommentMutationInput!): UnpostCommentMutationPayload + + """ + Given a transaction, save it to the database to be resent + """ + watchTransaction(input: WatchTransactionMutationInput!): WatchTransactionMutationPayload } """ @@ -1587,6 +1607,16 @@ type Query { """ id: String! ): AuthorizedUser + + """ + Given a transaction txhash, find the last watched transaction in the group with that txhash. + """ + watchedTransaction( + """ + Find the last watched transaction in the group with that txhash + """ + txhash: String! + ): WatchedTransaction } """ @@ -1652,6 +1682,61 @@ A rejection rason represented by a string that comes form `RejectionReason.value """ scalar RejectionReasonValue +""" +Autogenerated input type of ResendTransactionMutation +""" +input ResendTransactionMutationInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + ID of the transaction to be resent + """ + id: ID! + + """ + Signed transaction in HEX format + """ + signedTransaction: String! + + """ + Transaction hash + """ + transactionHash: String! + + """ + The JSONified transaction data object + """ + transactionObject: JSONObject! +} + +""" +Autogenerated return type of ResendTransactionMutation +""" +type ResendTransactionMutationPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + Mutation errors + + Operation Errors: + - Previous transaction not found + - Unauthorized action + - Nonce is not the same as the previous + """ + errors: [UserError!]! + + """ + Newly created transaction + """ + watchedTransaction: WatchedTransaction +} + """ Customer residence proof for KYC submission """ @@ -2413,4 +2498,72 @@ enum VotingStageEnum { Voters reveal their vote """ REVEAL +} + +""" +Autogenerated input type of WatchTransactionMutation +""" +input WatchTransactionMutationInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + Signed transaction in HEX format + """ + signedTransaction: String! + + """ + Transaction hash + """ + transactionHash: String! + + """ + The JSONified transaction data object + """ + transactionObject: JSONObject! +} + +""" +Autogenerated return type of WatchTransactionMutation +""" +type WatchTransactionMutationPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + Mutation errors + + Operation Errors: + - Invalid transaction object + """ + errors: [UserError!]! + + """ + Newly created transaction + """ + watchedTransaction: WatchedTransaction +} + +""" +Transactions that are being watched in the blockchain +""" +type WatchedTransaction { + """ + UUID of the watched transaction + """ + id: ID! + + """ + The JSONified transaction data object + """ + transactionObject: JSON! + + """ + Signer of the transaction + """ + user: User! } \ No newline at end of file diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index b2d53f7..fa5a474 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -18,5 +18,8 @@ class MutationType < Types::Base::BaseObject field :unpost_comment, mutation: Mutations::UnpostCommentMutation field :ban_comment, mutation: Mutations::BanCommentMutation field :unban_comment, mutation: Mutations::UnbanCommentMutation + + field :watch_transaction, mutation: Mutations::WatchTransactionMutation + field :resend_transaction, mutation: Mutations::ResendTransactionMutation end end diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb index e7d4f7c..63f6b83 100644 --- a/app/graphql/types/query_type.rb +++ b/app/graphql/types/query_type.rb @@ -74,6 +74,12 @@ def current_user Search for the current user's transactions. EOS + field :watched_transaction, + resolver: Resolvers::WatchedTransactionResolver, + description: <<~EOS + Given a transaction txhash, find the last watched transaction in the group with that txhash. + EOS + field :countries, resolver: Resolvers::CountriesResolver, description: 'List of countries to determine nationality for KYC' diff --git a/app/graphql/types/scalar/json_object.rb b/app/graphql/types/scalar/json_object.rb new file mode 100644 index 0000000..1f99b33 --- /dev/null +++ b/app/graphql/types/scalar/json_object.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Types + module Scalar + class JSONObject < Types::Base::BaseScalar + description <<~EOS + The JSONified data object + EOS + + def self.coerce_input(input, _context) + data = JSON.parse(input) + unless data.is_a?(Hash) + raise GraphQL::CoercionError, "#{input.inspect} is not a JSON object" + end + + data + rescue JSON::ParserError + raise GraphQL::CoercionError, "#{input.inspect} is not a valid JSON" + end + + def self.coerce_result(value, _context) + value + end + end + end +end diff --git a/app/graphql/types/watched_transaction/watched_transaction_type.rb b/app/graphql/types/watched_transaction/watched_transaction_type.rb new file mode 100644 index 0000000..377661a --- /dev/null +++ b/app/graphql/types/watched_transaction/watched_transaction_type.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Types + module WatchedTransaction + class WatchedTransactionType < Types::Base::BaseObject + description 'Transactions that are being watched in the blockchain' + + field :id, ID, + null: false, + description: 'UUID of the watched transaction' + + field :user, Types::User::UserType, + null: false, + description: 'Signer of the transaction' + + field :transaction_object, GraphQL::Types::JSON, + null: false, + description: 'The JSONified transaction data object' + end + end +end diff --git a/app/models/watching_transaction.rb b/app/models/watching_transaction.rb new file mode 100644 index 0000000..a245488 --- /dev/null +++ b/app/models/watching_transaction.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require 'ethereum_api' + +class WatchingTransaction < ApplicationRecord + before_create :set_uuid + + belongs_to :user + + validates :transaction_object, + presence: true + validates :signed_transaction, + presence: true + validates :txhash, + presence: true, + uniqueness: true + + def set_uuid + self.id ||= SecureRandom.uuid + self.group_id ||= id + end + + def transaction_object + JSON.parse(super) + end + + def transaction_object=(value) + super(JSON.generate(value)) + end + + def txhash=(value) + super(value&.downcase) + end + + class << self + def watch(user, attrs) + tx = WatchingTransaction.new( + user: user, + transaction_object: attrs.fetch(:transaction_object, nil), + signed_transaction: attrs.fetch(:signed_transaction, nil), + txhash: attrs.fetch(:txhash, nil) + ) + + return [:invalid_data, tx.errors] unless tx.valid? + return [:database_error, tx.errors] unless tx.save + + [:ok, tx] + end + + def resend(user, watching_transaction, attrs) + transaction_object = attrs.fetch(:transaction_object, nil) + unless user.id == watching_transaction.user.id + return [:unauthorized_action, nil] + end + unless transaction_object.fetch('nonce', nil) == watching_transaction.transaction_object['nonce'] + return [:invalid_nonce, nil] + end + + tx = WatchingTransaction.new( + user: user, + transaction_object: transaction_object, + signed_transaction: attrs.fetch(:signed_transaction, nil), + txhash: attrs.fetch(:txhash, nil), + group_id: watching_transaction.group_id + ) + + return [:invalid_data, tx.errors] unless tx.valid? + return [:database_error, tx.errors] unless tx.save + + [:ok, tx] + end + + def resend_transactions + group_size = WatchingTransaction.group(:group_id).count + WatchingTransaction.order('created_at ASC').each do |tx| + ok_tx, data = EthereumApi.get_transaction_by_hash(tx.txhash) + if ok_tx == :error + Rails.logger.info 'Failed to get transaction by hash. Killing job..' + break + end + unless data + if group_size[tx.group_id] == 1 + ok_send, txhash = EthereumApi.send_raw_transaction(tx.signed_transaction) + if ok_send == :ok + Rails.logger.info "Resent transaction #{tx.txhash}, new hash is #{txhash}" + tx.update_attributes(txhash: txhash) + else + Rails.logger.info "Failed to resend #{tx.txhash}" + end + else + Rails.logger.info "Destroying dropped transaction #{tx.txhash}" + tx.destroy + group_size[tx.group_id] -= 1 + end + next + end + + unless data['block_number'].nil? + Rails.logger.info "Destroying transactions from mined group #{tx.group_id}" + WatchingTransaction.where(group_id: tx.group_id).destroy_all + end + end + end + end +end diff --git a/db/migrate/20190826132750_create_watching_transactions.rb b/db/migrate/20190826132750_create_watching_transactions.rb new file mode 100644 index 0000000..885879d --- /dev/null +++ b/db/migrate/20190826132750_create_watching_transactions.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class CreateWatchingTransactions < ActiveRecord::Migration[5.2] + def change + create_table :watching_transactions, id: false do |t| + t.string :id, limit: 36, primary_key: true, null: false + t.string :group_id, limit: 36, index: true, null: false + t.string :transaction_object, null: false + t.string :signed_transaction, null: false + t.string :txhash + t.references :user, foreign_key: true + t.timestamps + end + + add_index :watching_transactions, :txhash, unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index dc8d6ed..fed95fd 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -1,5 +1,3 @@ -# frozen_string_literal: true - # This file is auto-generated from the current state of the database. Instead # of editing this file, please use the migrations feature of Active Record to # incrementally modify your database, and then regenerate this schema definition. @@ -12,202 +10,219 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20_190_226_235_957) do - create_table 'active_storage_attachments', options: 'ENGINE=InnoDB DEFAULT CHARSET=utf8', force: :cascade do |t| - t.string 'name', null: false - t.string 'record_type', null: false - t.bigint 'record_id', null: false - t.bigint 'blob_id', null: false - t.datetime 'created_at', null: false - t.index ['blob_id'], name: 'index_active_storage_attachments_on_blob_id' - t.index %w[record_type record_id name blob_id], name: 'index_active_storage_attachments_uniqueness', unique: true - end - - create_table 'active_storage_blobs', options: 'ENGINE=InnoDB DEFAULT CHARSET=utf8', force: :cascade do |t| - t.string 'key', null: false - t.string 'filename', null: false - t.string 'content_type' - t.text 'metadata' - t.bigint 'byte_size', null: false - t.string 'checksum', null: false - t.datetime 'created_at', null: false - t.index ['key'], name: 'index_active_storage_blobs_on_key', unique: true - end - - create_table 'challenges', options: 'ENGINE=InnoDB DEFAULT CHARSET=utf8', force: :cascade do |t| - t.string 'challenge' - t.boolean 'proven', default: false - t.bigint 'user_id' - t.datetime 'created_at', null: false - t.datetime 'updated_at', null: false - t.index ['user_id'], name: 'index_challenges_on_user_id' - end - - create_table 'comment_hierarchies', id: false, options: 'ENGINE=InnoDB DEFAULT CHARSET=utf8', force: :cascade do |t| - t.integer 'ancestor_id', null: false - t.integer 'descendant_id', null: false - t.integer 'generations', null: false - t.index %w[ancestor_id descendant_id generations], name: 'comment_anc_desc_idx', unique: true - t.index ['descendant_id'], name: 'comment_desc_idx' - end - - create_table 'comment_likes', options: 'ENGINE=InnoDB DEFAULT CHARSET=utf8', force: :cascade do |t| - t.bigint 'user_id' - t.bigint 'comment_id' - t.index ['comment_id'], name: 'index_comment_likes_on_comment_id' - t.index %w[user_id comment_id], name: 'index_comment_likes_on_user_id_and_comment_id', unique: true - t.index ['user_id'], name: 'index_comment_likes_on_user_id' - end - - create_table 'comments', options: 'ENGINE=InnoDB DEFAULT CHARSET=utf8', force: :cascade do |t| - t.text 'body' - t.integer 'stage', default: 1 - t.bigint 'user_id' - t.integer 'likes', default: 0 - t.integer 'parent_id' - t.datetime 'discarded_at' - t.datetime 'created_at', null: false - t.datetime 'updated_at', null: false - t.boolean 'is_banned', default: false, null: false - t.index ['discarded_at'], name: 'index_comments_on_discarded_at' - t.index ['stage'], name: 'index_comments_on_stage' - t.index ['user_id'], name: 'index_comments_on_user_id' - end - - create_table 'groups', options: 'ENGINE=InnoDB DEFAULT CHARSET=utf8', force: :cascade do |t| - t.string 'name', null: false - t.datetime 'created_at', null: false - t.datetime 'updated_at', null: false - end - - create_table 'groups_users', id: false, options: 'ENGINE=InnoDB DEFAULT CHARSET=utf8', force: :cascade do |t| - t.bigint 'user_id', null: false - t.bigint 'group_id', null: false - t.index %w[user_id group_id], name: 'index_groups_users_on_user_id_and_group_id', unique: true - end - - create_table 'kycs', options: 'ENGINE=InnoDB DEFAULT CHARSET=utf8', force: :cascade do |t| - t.integer 'status' - t.string 'first_name' - t.string 'last_name' - t.integer 'gender' - t.date 'birthdate' - t.string 'nationality' - t.string 'birth_country' - t.string 'phone_number' - t.integer 'employment_status' - t.string 'employment_industry' - t.string 'income_range' - t.integer 'identification_proof_type' - t.date 'identification_proof_expiration_date' - t.string 'identification_proof_number' - t.integer 'residence_proof_type' - t.string 'country' - t.string 'address' - t.string 'address_details' - t.string 'city' - t.string 'state' - t.string 'postal_code' - t.string 'verification_code' - t.date 'expiration_date' - t.string 'rejection_reason' - t.string 'approval_txhash' - t.datetime 'discarded_at' - t.bigint 'user_id' - t.bigint 'officer_id' - t.datetime 'created_at', null: false - t.datetime 'updated_at', null: false - t.bigint 'users_id' - t.index ['discarded_at'], name: 'index_kycs_on_discarded_at' - t.index ['officer_id'], name: 'index_kycs_on_officer_id' - t.index ['status'], name: 'index_kycs_on_status' - t.index ['user_id'], name: 'index_kycs_on_user_id' - t.index ['users_id'], name: 'index_kycs_on_users_id' - end - - create_table 'nonces', options: 'ENGINE=InnoDB DEFAULT CHARSET=utf8', force: :cascade do |t| - t.string 'server' - t.integer 'nonce' - t.datetime 'created_at', null: false - t.datetime 'updated_at', null: false - end - - create_table 'proposal_likes', options: 'ENGINE=InnoDB DEFAULT CHARSET=utf8', force: :cascade do |t| - t.bigint 'user_id' - t.bigint 'proposal_id' - t.index ['proposal_id'], name: 'index_proposal_likes_on_proposal_id' - t.index %w[user_id proposal_id], name: 'index_proposal_likes_on_user_id_and_proposal_id', unique: true - t.index ['user_id'], name: 'index_proposal_likes_on_user_id' - end - - create_table 'proposals', options: 'ENGINE=InnoDB DEFAULT CHARSET=utf8', force: :cascade do |t| - t.string 'proposal_id', default: '', null: false - t.bigint 'user_id' - t.integer 'stage', default: 1 - t.integer 'likes', default: 0 - t.datetime 'created_at', null: false - t.datetime 'updated_at', null: false - t.bigint 'comment_id' - t.index ['comment_id'], name: 'index_proposals_on_comment_id' - t.index ['proposal_id'], name: 'index_proposals_on_proposal_id', unique: true - t.index ['stage'], name: 'index_proposals_on_stage' - t.index ['user_id'], name: 'index_proposals_on_user_id' - end - - create_table 'test_images', options: 'ENGINE=InnoDB DEFAULT CHARSET=utf8', force: :cascade do |t| - end - - create_table 'transactions', options: 'ENGINE=InnoDB DEFAULT CHARSET=utf8', force: :cascade do |t| - t.string 'title' - t.string 'txhash' - t.string 'status', default: 'pending' - t.integer 'block_number' - t.bigint 'user_id' - t.datetime 'created_at', null: false - t.datetime 'updated_at', null: false - t.integer 'transaction_type' - t.string 'project' - t.index ['txhash'], name: 'index_transactions_on_txhash', unique: true - t.index ['user_id'], name: 'index_transactions_on_user_id' - end - - create_table 'user_audits', options: 'ENGINE=InnoDB DEFAULT CHARSET=utf8', force: :cascade do |t| - t.integer 'user_id', null: false - t.string 'event', null: false - t.string 'field', null: false - t.string 'old_value', null: false - t.string 'new_value', null: false - t.datetime 'created_at', null: false - t.datetime 'updated_at', null: false - end - - create_table 'users', options: 'ENGINE=InnoDB DEFAULT CHARSET=utf8', force: :cascade do |t| - t.string 'provider', default: 'address', null: false - t.string 'uid', default: '', null: false - t.datetime 'remember_created_at' - t.integer 'sign_in_count', default: 0, null: false - t.datetime 'current_sign_in_at' - t.datetime 'last_sign_in_at' - t.string 'current_sign_in_ip' - t.string 'last_sign_in_ip' - t.string 'address', default: '0x0', null: false - t.text 'tokens' - t.datetime 'created_at', null: false - t.datetime 'updated_at', null: false - t.string 'username', limit: 20 - t.string 'email', limit: 254 - t.boolean 'is_banned', default: false, null: false - t.index ['address'], name: 'index_users_on_address', unique: true - t.index ['email'], name: 'index_users_on_email', unique: true - t.index %w[uid provider], name: 'index_users_on_uid_and_provider', unique: true - t.index ['username'], name: 'index_users_on_username', unique: true - end - - add_foreign_key 'challenges', 'users' - add_foreign_key 'comments', 'users' - add_foreign_key 'kycs', 'users' - add_foreign_key 'kycs', 'users', column: 'users_id' - add_foreign_key 'proposals', 'comments' - add_foreign_key 'proposals', 'users' - add_foreign_key 'transactions', 'users' +ActiveRecord::Schema.define(version: 2019_08_26_132750) do + + create_table "active_storage_attachments", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8", force: :cascade do |t| + t.string "name", null: false + t.string "record_type", null: false + t.bigint "record_id", null: false + t.bigint "blob_id", null: false + t.datetime "created_at", null: false + t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id" + t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true + end + + create_table "active_storage_blobs", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8", force: :cascade do |t| + t.string "key", null: false + t.string "filename", null: false + t.string "content_type" + t.text "metadata" + t.bigint "byte_size", null: false + t.string "checksum", null: false + t.datetime "created_at", null: false + t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true + end + + create_table "challenges", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8", force: :cascade do |t| + t.string "challenge" + t.boolean "proven", default: false + t.bigint "user_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["user_id"], name: "index_challenges_on_user_id" + end + + create_table "comment_hierarchies", id: false, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8", force: :cascade do |t| + t.integer "ancestor_id", null: false + t.integer "descendant_id", null: false + t.integer "generations", null: false + t.index ["ancestor_id", "descendant_id", "generations"], name: "comment_anc_desc_idx", unique: true + t.index ["descendant_id"], name: "comment_desc_idx" + end + + create_table "comment_likes", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8", force: :cascade do |t| + t.bigint "user_id" + t.bigint "comment_id" + t.index ["comment_id"], name: "index_comment_likes_on_comment_id" + t.index ["user_id", "comment_id"], name: "index_comment_likes_on_user_id_and_comment_id", unique: true + t.index ["user_id"], name: "index_comment_likes_on_user_id" + end + + create_table "comments", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8", force: :cascade do |t| + t.text "body" + t.integer "stage", default: 1 + t.bigint "user_id" + t.integer "likes", default: 0 + t.integer "parent_id" + t.datetime "discarded_at" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.boolean "is_banned", default: false, null: false + t.index ["discarded_at"], name: "index_comments_on_discarded_at" + t.index ["stage"], name: "index_comments_on_stage" + t.index ["user_id"], name: "index_comments_on_user_id" + end + + create_table "groups", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8", force: :cascade do |t| + t.string "name", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + create_table "groups_users", id: false, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8", force: :cascade do |t| + t.bigint "user_id", null: false + t.bigint "group_id", null: false + t.index ["user_id", "group_id"], name: "index_groups_users_on_user_id_and_group_id", unique: true + end + + create_table "kycs", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8", force: :cascade do |t| + t.integer "status" + t.string "first_name" + t.string "last_name" + t.integer "gender" + t.date "birthdate" + t.string "nationality" + t.string "birth_country" + t.string "phone_number" + t.integer "employment_status" + t.string "employment_industry" + t.string "income_range" + t.integer "identification_proof_type" + t.date "identification_proof_expiration_date" + t.string "identification_proof_number" + t.integer "residence_proof_type" + t.string "country" + t.string "address" + t.string "address_details" + t.string "city" + t.string "state" + t.string "postal_code" + t.string "verification_code" + t.date "expiration_date" + t.string "rejection_reason" + t.string "approval_txhash" + t.datetime "discarded_at" + t.bigint "user_id" + t.bigint "officer_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.bigint "users_id" + t.index ["discarded_at"], name: "index_kycs_on_discarded_at" + t.index ["officer_id"], name: "index_kycs_on_officer_id" + t.index ["status"], name: "index_kycs_on_status" + t.index ["user_id"], name: "index_kycs_on_user_id" + t.index ["users_id"], name: "index_kycs_on_users_id" + end + + create_table "nonces", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8", force: :cascade do |t| + t.string "server" + t.integer "nonce" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + create_table "proposal_likes", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8", force: :cascade do |t| + t.bigint "user_id" + t.bigint "proposal_id" + t.index ["proposal_id"], name: "index_proposal_likes_on_proposal_id" + t.index ["user_id", "proposal_id"], name: "index_proposal_likes_on_user_id_and_proposal_id", unique: true + t.index ["user_id"], name: "index_proposal_likes_on_user_id" + end + + create_table "proposals", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8", force: :cascade do |t| + t.string "proposal_id", default: "", null: false + t.bigint "user_id" + t.integer "stage", default: 1 + t.integer "likes", default: 0 + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.bigint "comment_id" + t.index ["comment_id"], name: "index_proposals_on_comment_id" + t.index ["proposal_id"], name: "index_proposals_on_proposal_id", unique: true + t.index ["stage"], name: "index_proposals_on_stage" + t.index ["user_id"], name: "index_proposals_on_user_id" + end + + create_table "test_images", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8", force: :cascade do |t| + end + + create_table "transactions", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8", force: :cascade do |t| + t.string "title" + t.string "txhash" + t.string "status", default: "pending" + t.integer "block_number" + t.bigint "user_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.integer "transaction_type" + t.string "project" + t.bigint "group_id" + t.index ["group_id"], name: "index_transactions_on_group_id" + t.index ["txhash"], name: "index_transactions_on_txhash", unique: true + t.index ["user_id"], name: "index_transactions_on_user_id" + end + + create_table "user_audits", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8", force: :cascade do |t| + t.integer "user_id", null: false + t.string "event", null: false + t.string "field", null: false + t.string "old_value", null: false + t.string "new_value", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + create_table "users", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8", force: :cascade do |t| + t.string "provider", default: "address", null: false + t.string "uid", default: "", null: false + t.datetime "remember_created_at" + t.integer "sign_in_count", default: 0, null: false + t.datetime "current_sign_in_at" + t.datetime "last_sign_in_at" + t.string "current_sign_in_ip" + t.string "last_sign_in_ip" + t.string "address", default: "0x0", null: false + t.text "tokens" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.string "username", limit: 20 + t.string "email", limit: 254 + t.boolean "is_banned", default: false, null: false + t.index ["address"], name: "index_users_on_address", unique: true + t.index ["email"], name: "index_users_on_email", unique: true + t.index ["uid", "provider"], name: "index_users_on_uid_and_provider", unique: true + t.index ["username"], name: "index_users_on_username", unique: true + end + + create_table "watching_transactions", id: :string, limit: 36, options: "ENGINE=InnoDB DEFAULT CHARSET=utf8", force: :cascade do |t| + t.string "group_id", limit: 36, null: false + t.string "transaction_object", null: false + t.string "signed_transaction", null: false + t.string "txhash" + t.bigint "user_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["group_id"], name: "index_watching_transactions_on_group_id" + t.index ["txhash"], name: "index_watching_transactions_on_txhash", unique: true + t.index ["user_id"], name: "index_watching_transactions_on_user_id" + end + + add_foreign_key "challenges", "users" + add_foreign_key "comments", "users" + add_foreign_key "kycs", "users" + add_foreign_key "kycs", "users", column: "users_id" + add_foreign_key "proposals", "comments" + add_foreign_key "proposals", "users" + add_foreign_key "transactions", "users" + add_foreign_key "watching_transactions", "users" end diff --git a/lib/ethereum_api.rb b/lib/ethereum_api.rb index 20cf6a0..c246c26 100644 --- a/lib/ethereum_api.rb +++ b/lib/ethereum_api.rb @@ -16,6 +16,14 @@ def get_block_by_block_number(block_number) unwrap_result(request_ethereum_server('eth_getBlockByNumber', [block_number, false])) end + def get_transaction_by_hash(txhash) + unwrap_result(request_ethereum_server('eth_getTransactionByHash', [txhash])) + end + + def send_raw_transaction(data) + unwrap_result(request_ethereum_server('eth_sendRawTransaction', [data])) + end + private def request_ethereum_server(method_name, method_args) diff --git a/lib/scheduler.rb b/lib/scheduler.rb index 2a78b29..536373e 100644 --- a/lib/scheduler.rb +++ b/lib/scheduler.rb @@ -48,4 +48,11 @@ puts 'Cleaning up old challenges took too long.' end +scheduler.every '5m' do + puts 'Watching transactions' + WatchingTransaction.resend_transactions +rescue Rufus::Scheduler::TimeoutError + puts 'Watching transactions took too long.' +end + scheduler.join diff --git a/test/factories/watching_transactions.rb b/test/factories/watching_transactions.rb new file mode 100644 index 0000000..52ec586 --- /dev/null +++ b/test/factories/watching_transactions.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'faker' + +static_nonce = Random.rand(1001..2000) + +FactoryBot.define do + sequence(:transaction_object) do |_| + { 'nonce' => Random.rand(1..1000) } + end + sequence(:fixed_transaction_object) do |_| + { 'nonce' => static_nonce } + end + sequence(:signed_transaction) { |_| SecureRandom.hex(32) } + sequence(:id) { |_| SecureRandom.hex(32) } + + factory :watching_transaction, class: 'WatchingTransaction' do + txhash { generate(:txhash) } + transaction_object { generate(:fixed_transaction_object) } + signed_transaction { generate(:signed_transaction) } + association :user, factory: :user + end + + factory :watch_transaction, class: 'Hash' do + transactionHash { generate(:txhash) } + transactionObject { JSON.generate(generate(:transaction_object)) } + signedTransaction { generate(:signed_transaction) } + + factory :watch_transaction_resend do + id { generate(:id) } + transactionObject { JSON.generate(generate(:fixed_transaction_object)) } + end + end +end diff --git a/test/graphql/resend_transaction_mutation_test.rb b/test/graphql/resend_transaction_mutation_test.rb new file mode 100644 index 0000000..5e2f282 --- /dev/null +++ b/test/graphql/resend_transaction_mutation_test.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require 'test_helper' + +class ResendTransactionMutationTest < ActiveSupport::TestCase + QUERY = <<~EOS + mutation($id: ID!, $transactionHash: String!, $transactionObject: JSONObject!, $signedTransaction: String!) { + resendTransaction(input: { id: $id, transactionHash: $transactionHash, transactionObject: $transactionObject, signedTransaction: $signedTransaction }) { + watchedTransaction { + id + user { + address + } + transactionObject + } + errors { + field + message + } + } + } + EOS + + test 'resend transaction mutation should work' do + old_transaction = create(:watching_transaction) + attrs = attributes_for(:watch_transaction_resend, id: old_transaction.id) + + tx_result = DaoServerSchema.execute( + QUERY, + context: { current_user: old_transaction.user }, + variables: attrs + ) + + assert_nil tx_result['errors'], + 'should work and have no errors' + assert_empty tx_result['data']['resendTransaction']['errors'], + 'should have no errors' + + data = tx_result['data']['resendTransaction']['watchedTransaction'] + + assert_equal JSON.parse(attrs[:transactionObject])['nonce'], data['transactionObject']['nonce'], + 'nonce should be the same' + end + + test 'resend transaction should fail safely' do + unauthorized_result = DaoServerSchema.execute( + QUERY, + context: {}, + variables: {} + ) + + assert_not_empty unauthorized_result['errors'], + 'should fail without a user' + + old_transaction = create(:watching_transaction) + attrs = attributes_for(:watch_transaction_resend) + + invalid_group_result = DaoServerSchema.execute( + QUERY, + context: { current_user: old_transaction.user }, + variables: attrs + ) + + assert_not_empty invalid_group_result['data']['resendTransaction']['errors'], + 'should fail with invalid id' + + invalid_nonce_result = DaoServerSchema.execute( + QUERY, + context: { current_user: old_transaction.user }, + variables: attributes_for(:watch_transaction, id: old_transaction.id) + ) + + assert_not_empty invalid_nonce_result['data']['resendTransaction']['errors'], + 'should fail with invalid nonce' + end +end diff --git a/test/graphql/watch_transaction_mutation_test.rb b/test/graphql/watch_transaction_mutation_test.rb new file mode 100644 index 0000000..43d7895 --- /dev/null +++ b/test/graphql/watch_transaction_mutation_test.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require 'test_helper' + +class WatchTransactionMutationTest < ActiveSupport::TestCase + QUERY = <<~EOS + mutation($transactionHash: String!, $transactionObject: JSONObject!, $signedTransaction: String!) { + watchTransaction(input: { transactionHash: $transactionHash, transactionObject: $transactionObject, signedTransaction: $signedTransaction }) { + watchedTransaction { + id + user { + address + } + transactionObject + } + errors { + field + message + } + } + } + EOS + + test 'watch transaction mutation should work' do + user = create(:user) + attrs = attributes_for(:watch_transaction) + + tx_result = DaoServerSchema.execute( + QUERY, + context: { current_user: user }, + variables: attrs + ) + + assert_nil tx_result['errors'], + 'should work and have no errors' + assert_empty tx_result['data']['watchTransaction']['errors'], + 'should have no errors' + + data = tx_result['data']['watchTransaction']['watchedTransaction'] + + assert_equal JSON.parse(attrs[:transactionObject]), data['transactionObject'], + 'transactionObject should be the same' + end + + test 'watch transaction should fail safely' do + unauthorized_result = DaoServerSchema.execute( + QUERY, + context: {}, + variables: {} + ) + + assert_not_empty unauthorized_result['errors'], + 'should fail without a user' + end +end diff --git a/test/graphql/watched_transaction_query_test.rb b/test/graphql/watched_transaction_query_test.rb new file mode 100644 index 0000000..f6fa32a --- /dev/null +++ b/test/graphql/watched_transaction_query_test.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'test_helper' + +class WatchedTransactionQueryTest < ActiveSupport::TestCase + QUERY = <<~EOS + query($txhash: String!) { + watchedTransaction(txhash: $txhash) { + id + user { + displayName + } + transactionObject + } + } + EOS + + test 'watched transaction should work' do + first_transaction = create(:watching_transaction) + last_transaction = create( + :watching_transaction, + group_id: first_transaction.group_id, + user: first_transaction.user, + created_at: Date.current + 1 + ) + result = DaoServerSchema.execute( + QUERY, + context: { current_user: first_transaction.user }, + variables: { txhash: first_transaction.txhash } + ) + + assert_nil result['errors'], + 'should work and have no errors' + + data = result['data']['watchedTransaction'] + + assert_not_empty data, + 'watchedTransaction type should work' + assert_equal last_transaction.id, data['id'], + 'should return last transaction from group' + + empty_result = DaoServerSchema.execute( + QUERY, + context: { current_user: first_transaction.user }, + variables: { txhash: 'NON_EXISTENT_TXHASH' } + ) + + assert_nil empty_result['data']['watchedTransaction'], + 'data should be empty on invalid txhash' + end +end diff --git a/test/models/watching_transaction_test.rb b/test/models/watching_transaction_test.rb new file mode 100644 index 0000000..697f02b --- /dev/null +++ b/test/models/watching_transaction_test.rb @@ -0,0 +1,178 @@ +# frozen_string_literal: true + +require 'test_helper' + +class WatchingTransactionTest < ActiveSupport::TestCase + test 'watch should work' do + user = create(:user) + attrs = attributes_for(:watching_transaction) + ok, tx = WatchingTransaction.watch(user, attrs) + + assert_equal :ok, ok, + 'should work' + assert_equal user.id, tx.user.id, + 'should have correct user id' + end + + test 'resend should work' do + user = create(:user) + watching_transaction = create(:watching_transaction, user: user) + attrs = attributes_for(:watching_transaction) + + ok, tx = WatchingTransaction.resend(user, watching_transaction, attrs) + + assert_equal :ok, ok, + 'should work' + assert_equal watching_transaction.group_id, tx.group_id, + 'should have the same group as the previous transaction' + end + + test 'resend transactions should keep all if none is mined' do + user = create(:user) + group_id = SecureRandom.uuid + size = SecureRandom.rand(1..6) + size.times do + FactoryBot.create(:watching_transaction, group_id: group_id, user: user) + end + + pending_stub = stub_request(:post, EthereumApi::SERVER_URL) + .with(body: /eth_getTransactionByHash/) + .to_return( + body: { + result: { + 'block_number' => nil + } + }.to_json + ) + + WatchingTransaction.resend_transactions + + assert_requested(pending_stub, times: size) + assert_equal size, WatchingTransaction.where(group_id: group_id).count, + 'should work' + end + + test 'resend transactions should destroy all if one is mined' do + user = create(:user) + group_id = SecureRandom.uuid + size = SecureRandom.rand(1..6) + transaction_group = (1..size).map do + FactoryBot.create(:watching_transaction, group_id: group_id, user: user) + end + + pending_stub = stub_request(:post, EthereumApi::SERVER_URL) + .with(body: /eth_getTransactionByHash/) + .to_return( + body: { + result: { + 'block_number' => nil + } + }.to_json + ) + + mined_stub = stub_request(:post, EthereumApi::SERVER_URL) + .with(body: /eth_getTransactionByHash.*#{transaction_group.sample.txhash}/) + .to_return( + body: { + result: { + 'block_number' => SecureRandom.hex(8) + } + }.to_json + ) + + WatchingTransaction.resend_transactions + + assert_requested(pending_stub, times: size) + assert_requested(mined_stub, times: 1) + assert_equal 0, WatchingTransaction.where(group_id: group_id).count, + 'should destroy all if one is mined' + end + + test 'resend transactions should resend latest if rest are dropped' do + user = create(:user) + group_id = SecureRandom.uuid + size = SecureRandom.rand(1..6) + size.times do + FactoryBot.create(:watching_transaction, group_id: group_id, user: user) + end + latest = FactoryBot.create(:watching_transaction, group_id: group_id, user: user, created_at: Date.current + 1) + + dropped_stub = stub_request(:post, EthereumApi::SERVER_URL) + .with(body: /eth_getTransactionByHash/) + .to_return( + body: { + result: nil + }.to_json + ) + + new_txhash = generate(:txhash) + send_transaction_stub = stub_request(:post, EthereumApi::SERVER_URL) + .with(body: /eth_sendRawTransaction/) + .to_return( + body: { + result: new_txhash + }.to_json + ) + + WatchingTransaction.resend_transactions + + assert_requested(dropped_stub, times: size + 1) + assert_requested(send_transaction_stub, times: 1) + assert_equal 1, WatchingTransaction.where(group_id: group_id).count, + 'should work' + + resent = WatchingTransaction.find_by(id: latest.id) + assert_equal new_txhash, resent.txhash, + 'should update txhash for resent transaction' + end + + test 'resend transactions should keep all if get transaction fails' do + user = create(:user) + group_id = SecureRandom.uuid + size = SecureRandom.rand(1..6) + size.times do + FactoryBot.create(:watching_transaction, group_id: group_id, user: user) + end + + get_failed_stub = stub_request(:post, EthereumApi::SERVER_URL) + .with(body: /eth_getTransactionByHash/) + .to_return(status: [500, 'Internal Server Error']) + + WatchingTransaction.resend_transactions + + assert_requested(get_failed_stub, times: 1) + assert_equal size, WatchingTransaction.where(group_id: group_id).count, + 'should work' + end + + test 'resend transactions should keep latest if send transaction fails' do + user = create(:user) + group_id = SecureRandom.uuid + size = SecureRandom.rand(1..6) + size.times do + FactoryBot.create(:watching_transaction, group_id: group_id, user: user) + end + latest = FactoryBot.create(:watching_transaction, group_id: group_id, user: user, created_at: Date.current + 1) + + dropped_stub = stub_request(:post, EthereumApi::SERVER_URL) + .with(body: /eth_getTransactionByHash/) + .to_return( + body: { + result: nil + }.to_json + ) + + send_transaction_failed_stub = stub_request(:post, EthereumApi::SERVER_URL) + .with(body: /eth_sendRawTransaction/) + .to_return(status: [500, 'Internal Server Error']) + + WatchingTransaction.resend_transactions + + assert_requested(dropped_stub, times: size + 1) + assert_requested(send_transaction_failed_stub, times: 1) + + not_resent = WatchingTransaction.find_by(id: latest.id) + assert_equal latest.txhash, not_resent.txhash, + 'should keep txhash' + end +end