From a8ccc16aeae6068b4884aec70d62d5b17e0ca19c Mon Sep 17 00:00:00 2001 From: Rabbit Date: Thu, 25 Jan 2024 13:52:26 +0800 Subject: [PATCH] feat: add portfolio sign in (#1484) * feat: add portfolio sign in * feat: update user name * feat: add user portfolio statistic * chore: fix test helper * feat: sync portfolio addresses * feat: add portfolio statistic * feat: added sort to portfolio transactions * chore: refactor filter portfolio account books query * feat: add download portfolio transactions * chore: delete portfolio statistic * chore: adjust tests * chore: update env test conf * chore: adjust tests --- .env.example | 12 +- .env.test.local.travis | 6 +- Gemfile | 2 + Gemfile.lock | 4 + app/controllers/api/v2/base_controller.rb | 22 +++- .../api/v2/portfolio/addresses_controller.rb | 18 +++ .../portfolio/ckb_transactions_controller.rb | 87 +++++++++++++ .../api/v2/portfolio/sessions_controller.rb | 32 +++++ .../api/v2/portfolio/statistics_controller.rb | 40 ++++++ .../v2/portfolio/udt_accounts_controller.rb | 22 ++++ .../api/v2/portfolio/users_controller.rb | 15 +++ .../validations/portfolio_signature.rb | 63 +++++++++ .../export_portfolio_transactions_job.rb | 121 +++++++++++++++++ app/lib/api/v2/exceptions.rb | 44 ++++++- app/models/concerns/uuidable.rb | 15 +++ app/models/portfolio.rb | 29 +++++ app/models/user.rb | 23 ++++ app/services/portfolio_signature_verifier.rb | 48 +++++++ .../portfolios/udt_accounts_statistic.rb | 88 +++++++++++++ app/utils/portfolio_utils.rb | 12 ++ .../portfolio/statistics/index.json.jbuilder | 8 ++ config/routes/v2.rb | 14 +- db/migrate/20231017023456_create_users.rb | 14 ++ .../20231017024100_create_portfolios.rb | 10 ++ db/structure.sql | 123 ++++++++++++++++++ .../api/v1/udts_controller_test.rb | 4 +- .../v2/portfolio/addresses_controller_test.rb | 38 ++++++ .../v2/portfolio/sessions_controller_test.rb | 66 ++++++++++ .../portfolio/statistics_controller_test.rb | 47 +++++++ .../api/v2/portfolio/users_controller_test.rb | 56 ++++++++ test/factories/portfolio.rb | 6 + test/factories/user.rb | 11 ++ test/models/portfolio_test.rb | 7 + test/models/user_test.rb | 7 + .../portfolio_signature_verifier_test.rb | 33 +++++ 35 files changed, 1135 insertions(+), 12 deletions(-) create mode 100644 app/controllers/api/v2/portfolio/addresses_controller.rb create mode 100644 app/controllers/api/v2/portfolio/ckb_transactions_controller.rb create mode 100644 app/controllers/api/v2/portfolio/sessions_controller.rb create mode 100644 app/controllers/api/v2/portfolio/statistics_controller.rb create mode 100644 app/controllers/api/v2/portfolio/udt_accounts_controller.rb create mode 100644 app/controllers/api/v2/portfolio/users_controller.rb create mode 100644 app/controllers/validations/portfolio_signature.rb create mode 100644 app/jobs/csv_exportable/export_portfolio_transactions_job.rb create mode 100644 app/models/concerns/uuidable.rb create mode 100644 app/models/portfolio.rb create mode 100644 app/models/user.rb create mode 100644 app/services/portfolio_signature_verifier.rb create mode 100644 app/services/portfolios/udt_accounts_statistic.rb create mode 100644 app/utils/portfolio_utils.rb create mode 100644 app/views/api/v2/portfolio/statistics/index.json.jbuilder create mode 100644 db/migrate/20231017023456_create_users.rb create mode 100644 db/migrate/20231017024100_create_portfolios.rb create mode 100644 test/controllers/api/v2/portfolio/addresses_controller_test.rb create mode 100644 test/controllers/api/v2/portfolio/sessions_controller_test.rb create mode 100644 test/controllers/api/v2/portfolio/statistics_controller_test.rb create mode 100644 test/controllers/api/v2/portfolio/users_controller_test.rb create mode 100644 test/factories/portfolio.rb create mode 100644 test/factories/user.rb create mode 100644 test/models/portfolio_test.rb create mode 100644 test/models/user_test.rb create mode 100644 test/services/portfolio_signature_verifier_test.rb diff --git a/.env.example b/.env.example index 72ae7259f..5a1924ca1 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,4 @@ -# -------------------------------- CKB segment -------------------------------- +# -------------------------------- CKB segment -------------------------------- # very important, ckb config items # mainnet | testnet CKB_NET_MODE="mainnet" @@ -65,7 +65,7 @@ DB_REAP_FREQ=10 MEMCACHED_URL="memcached://ckb-explorer-memcached:11211" -# -------------------------------- 3rd deps segment -------------------------------- +# -------------------------------- 3rd deps segment -------------------------------- # default is https://indexer-basic.da.systems/v1/ DAS_INDEXER_URL="https://indexer-basic.da.systems/v1/" @@ -77,7 +77,7 @@ STAGING_DOMAIN="https://ckb-explorer.mainnet.layerview.io" #COTA_AGGREGATOR_URL="http://cota-aggregator:3030" -# -------------------------------- profiling segment -------------------------------- +# -------------------------------- profiling segment -------------------------------- # sentry config segment SENTRY_DSN="https://???@xx.ingest.sentry.io/xxx" SENTRY_SAMPLE_RATE="1.0" @@ -86,7 +86,7 @@ SENTRY_SAMPLE_RATE="1.0" NEWRELIC_LICENSE_KEY="" -# -------------------------------- misc segment -------------------------------- +# -------------------------------- misc segment -------------------------------- # used in statistics_controller # on or nil MINER_RANKING_EVENT="on" @@ -113,3 +113,7 @@ ASSET_URL="" # used in Rails test environment, setting to true enables SimpleCov::Formatter::Codecov # true | false CI="false" + +# -------------------------------- portfolio segment -------------------------------- +AUTH_ACCESS_EXPIRE=1296000 +SECRET_KEY_BASE="" \ No newline at end of file diff --git a/.env.test.local.travis b/.env.test.local.travis index ac65e8b11..94d3702c3 100644 --- a/.env.test.local.travis +++ b/.env.test.local.travis @@ -17,7 +17,7 @@ REDIS_URL="redis://localhost:6379/1" # side kiq, reaping_frequency, default is 10 DB_REAP_FREQ=10 -# -------------------------------- 3rd deps segment -------------------------------- +# -------------------------------- 3rd deps segment -------------------------------- # default is https://indexer-basic.da.systems/v1/ DAS_INDEXER_URL="https://indexer-basic.da.systems/v1/" @@ -45,3 +45,7 @@ PROPOSAL_WINDOW="10" # used in ./lib/tasks/migration/register_udt.rake FORCE_BRIDGE_HOST="" ASSET_URL="" + +# -------------------------------- portfolio segment -------------------------------- +AUTH_ACCESS_EXPIRE=1296000 +SECRET_KEY_BASE= \ No newline at end of file diff --git a/Gemfile b/Gemfile index ff1ef8e1d..0cb8bc32d 100644 --- a/Gemfile +++ b/Gemfile @@ -123,3 +123,5 @@ gem "after_commit_everywhere" gem "kredis" gem "async-websocket", "~> 0.22.1", require: false +gem "ecdsa" +gem "jwt" diff --git a/Gemfile.lock b/Gemfile.lock index a865302b5..81d127338 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -180,6 +180,7 @@ GEM dry-schema (>= 1.12, < 2) zeitwerk (~> 2.6) e2mmap (0.1.0) + ecdsa (1.2.0) erubi (1.11.0) et-orbi (1.2.7) tzinfo @@ -225,6 +226,7 @@ GEM actionview (>= 5.0.0) activesupport (>= 5.0.0) json (2.6.3) + jwt (2.7.1) kaminari (1.2.2) activesupport (>= 4.1.0) kaminari-actionview (= 1.2.2) @@ -488,6 +490,7 @@ DEPENDENCIES database_cleaner-active_record digest-crc dotenv-rails + ecdsa factory_bot_rails faker fast_jsonapi @@ -496,6 +499,7 @@ DEPENDENCIES hiredis-client http jbuilder + jwt kaminari kredis listen (>= 3.0.5) diff --git a/app/controllers/api/v2/base_controller.rb b/app/controllers/api/v2/base_controller.rb index e7710deba..918f64233 100644 --- a/app/controllers/api/v2/base_controller.rb +++ b/app/controllers/api/v2/base_controller.rb @@ -5,10 +5,8 @@ class BaseController < ActionController::API rescue_from Api::V2::Exceptions::Error, with: :api_error - protected - def address_to_lock_hash(address) - if address =~ /\A0x/ + if address.start_with?("0x") address else parsed = CkbUtils.parse_address(address) @@ -24,6 +22,24 @@ def pagy_get_items(collection, pagy) def api_error(error) render json: RequestErrorSerializer.new([error], message: error.title), status: error.status end + + attr_reader :current_user + + def validate_jwt! + jwt = request.headers["Authorization"]&.split&.last + payload = PortfolioUtils.decode_jwt(jwt) + + user = User.find_by(uuid: payload[0]["uuid"]) + raise Api::V2::Exceptions::UserNotExistError.new("validate jwt") unless user + + @current_user = user + rescue JWT::VerificationError => e + raise Api::V2::Exceptions::DecodeJWTFailedError.new(e.message) + rescue JWT::ExpiredSignature => e + raise Api::V2::Exceptions::DecodeJWTFailedError.new(e.message) + rescue JWT::DecodeError => e + raise Api::V2::Exceptions::DecodeJWTFailedError.new(e.message) + end end end end diff --git a/app/controllers/api/v2/portfolio/addresses_controller.rb b/app/controllers/api/v2/portfolio/addresses_controller.rb new file mode 100644 index 000000000..3925d326b --- /dev/null +++ b/app/controllers/api/v2/portfolio/addresses_controller.rb @@ -0,0 +1,18 @@ +module Api + module V2 + module Portfolio + class AddressesController < BaseController + before_action :validate_jwt! + + def create + address_hashes = params.fetch(:addresses, []) + ::Portfolio.sync_addresses(current_user, address_hashes) + + head :no_content + rescue StandardError => e + raise Api::V2::Exceptions::SyncPortfolioAddressesError + end + end + end + end +end diff --git a/app/controllers/api/v2/portfolio/ckb_transactions_controller.rb b/app/controllers/api/v2/portfolio/ckb_transactions_controller.rb new file mode 100644 index 000000000..49b06a017 --- /dev/null +++ b/app/controllers/api/v2/portfolio/ckb_transactions_controller.rb @@ -0,0 +1,87 @@ +module Api + module V2 + module Portfolio + class CkbTransactionsController < BaseController + before_action :validate_jwt! + before_action :pagination_params + + def index + expires_in 15.minutes, public: true, stale_while_revalidate: 5.minutes, stale_if_error: 5.minutes + + account_books = sort_account_books(filter_account_books).page(@page).per(@page_size).fast_page + ckb_transactions = CkbTransaction.where(id: account_books.map(&:ckb_transaction_id)). + select(:id, :tx_hash, :block_id, :block_number, :block_timestamp, + :is_cellbase, :updated_at, :capacity_involved). + order(id: :desc) + options = FastJsonapi::PaginationMetaGenerator.new( + request: request, + records: ckb_transactions, + page: @page, + page_size: @page_size, + records_counter: account_books + ).call + ckb_transaction_serializer = CkbTransactionsSerializer.new( + ckb_transactions, + options.merge(params: { + previews: true, + address: current_user.addresses + }) + ) + json = ckb_transaction_serializer.serialized_json + + render json: json + end + + def download_csv + args = download_params.merge(address_ids: current_user.address_ids) + file = CsvExportable::ExportPortfolioTransactionsJob.perform_now(args.to_h) + + send_data file, type: "text/csv; charset=utf-8; header=present", + disposition: "attachment;filename=portfolio_ckb_transactions.csv" + end + + private + + def pagination_params + @page = params[:page] || 1 + @page_size = params[:page_size] || CkbTransaction.default_per_page + end + + def filter_account_books + address_ids = + if params[:address_hash].present? + address = Address.find_address!(params[:address_hash]) + [address.id] + else + current_user.address_ids + end + scope = AccountBook.joins(:ckb_transaction).where( + account_books: { address_id: address_ids }, + ckb_transactions: { tx_status: "committed" } + ) + + if params[:tx_hash].present? + scope = scope.where(ckb_transactions: { tx_hash: params[:tx_hash] }) + end + + scope + end + + def sort_account_books(records) + sort, order = params.fetch(:sort, "ckb_transaction_id.desc").split(".", 2) + sort = "ckb_transactions.block_timestamp" if sort == "time" + + if order.nil? || !order.match?(/^(asc|desc)$/i) + order = "asc" + end + + records.order("#{sort} #{order}") + end + + def download_params + params.permit(:start_date, :end_date, :start_number, :end_number) + end + end + end + end +end diff --git a/app/controllers/api/v2/portfolio/sessions_controller.rb b/app/controllers/api/v2/portfolio/sessions_controller.rb new file mode 100644 index 000000000..12598576b --- /dev/null +++ b/app/controllers/api/v2/portfolio/sessions_controller.rb @@ -0,0 +1,32 @@ +module Api + module V2 + module Portfolio + class SessionsController < BaseController + before_action :validate_query_params + + def create + user = User.find_or_create_by(identifier: params[:address]) + payload = { uuid: user.uuid } + + render json: { + name: user.name, + jwt: PortfolioUtils.generate_jwt(payload) + } + end + + private + + def validate_query_params + validator = Validations::PortfolioSignature.new(params) + + if validator.invalid? + errors = validator.error_object[:errors] + status = validator.error_object[:status] + + render json: errors, status: status + end + end + end + end + end +end diff --git a/app/controllers/api/v2/portfolio/statistics_controller.rb b/app/controllers/api/v2/portfolio/statistics_controller.rb new file mode 100644 index 000000000..15a6b3301 --- /dev/null +++ b/app/controllers/api/v2/portfolio/statistics_controller.rb @@ -0,0 +1,40 @@ +module Api + module V2 + module Portfolio + class StatisticsController < BaseController + before_action :validate_jwt!, :check_addresses_consistent! + + def index + expires_in 30.minutes, public: true, stale_while_revalidate: 10.minutes, stale_if_error: 10.minutes + + addresses = current_user.addresses + balance = addresses.pluck(:balance).sum + balance_occupied = addresses.pluck(:balance_occupied).sum + dao_deposit = addresses.pluck(:dao_deposit).sum + interest = addresses.pluck(:interest).sum + unclaimed_compensation = addresses.pluck(:unclaimed_compensation).sum + + json = { + balance: balance.to_s, + balance_occupied: balance_occupied.to_s, + dao_deposit: dao_deposit.to_s, + interest: interest.to_s, + dao_compensation: (interest.to_i + unclaimed_compensation.to_i).to_s + } + + render json: { data: json } + end + + private + + def check_addresses_consistent! + address = Address.find_by_address_hash(params[:latest_address]) + unless current_user.portfolios.exists?(address: address) + latest_address = current_user.portfolios.last&.address + raise Api::V2::Exceptions::PortfolioLatestDiscrepancyError.new(latest_address&.address_hash) + end + end + end + end + end +end diff --git a/app/controllers/api/v2/portfolio/udt_accounts_controller.rb b/app/controllers/api/v2/portfolio/udt_accounts_controller.rb new file mode 100644 index 000000000..b1c06dc5e --- /dev/null +++ b/app/controllers/api/v2/portfolio/udt_accounts_controller.rb @@ -0,0 +1,22 @@ +module Api + module V2 + module Portfolio + class UdtAccountsController < BaseController + before_action :validate_jwt! + + def index + expires_in 30.minutes, public: true, stale_while_revalidate: 10.minutes, stale_if_error: 10.minutes + + statistic = Portfolios::UdtAccountsStatistic.new(current_user) + if params[:cell_type] == "sudt" + accounts = statistic.sudt_accounts(params[:published]) + else + accounts = statistic.nft_accounts + end + + render json: accounts + end + end + end + end +end diff --git a/app/controllers/api/v2/portfolio/users_controller.rb b/app/controllers/api/v2/portfolio/users_controller.rb new file mode 100644 index 000000000..63f1da811 --- /dev/null +++ b/app/controllers/api/v2/portfolio/users_controller.rb @@ -0,0 +1,15 @@ +module Api + module V2 + module Portfolio + class UsersController < BaseController + before_action :validate_jwt! + + def update + current_user.update(name: params[:name]) + + head :no_content + end + end + end + end +end diff --git a/app/controllers/validations/portfolio_signature.rb b/app/controllers/validations/portfolio_signature.rb new file mode 100644 index 000000000..1af5708e3 --- /dev/null +++ b/app/controllers/validations/portfolio_signature.rb @@ -0,0 +1,63 @@ +module Validations + class PortfolioSignature + include ActiveModel::Validations + + validate :address_format_must_be_correct + validate :message_format_must_be_correct + validate :signature_format_must_be_correct + validate :signature_must_be_valid + + def initialize(params = {}) + @address = params[:address] + @message = params[:message] + @signature = params[:signature] + @pub_key = params[:pub_key] + end + + def error_object + api_errors = [] + + if invalid? + api_errors << Api::V2::Exceptions::AddressNotMatchEnvironmentError.new(ENV["CKB_NET_MODE"]) if :address.in?(errors.attribute_names) + api_errors << Api::V2::Exceptions::InvalidPortfolioMessageError.new if :message.in?(errors.attribute_names) + api_errors << Api::V2::Exceptions::InvalidPortfolioSignatureError.new if :signature.in?(errors.attribute_names) + + { + status: api_errors.first.status, + errors: RequestErrorSerializer.new(api_errors, message: api_errors.first.title) + } + end + end + + private + + attr_accessor :address, :message, :signature, :pub_key + + def address_format_must_be_correct + if address.blank? || !QueryKeyUtils.valid_address?(address) + errors.add(:address, "address is invalid") + end + end + + def message_format_must_be_correct + if message.blank? || !QueryKeyUtils.hex_string?(message) + errors.add(:message, "message is invalid") + end + end + + def signature_format_must_be_correct + if signature.blank? || !QueryKeyUtils.hex_string?(signature) + errors.add(:signature, "signature is invalid") + end + end + + def signature_must_be_valid + return if errors.present? + + signature_valid = PortfolioSignatureVerifier.new(address, message, signature, pub_key).verified? + unless signature_valid + errors.add(:signature, "signature is invalid") + end + end + end +end diff --git a/app/jobs/csv_exportable/export_portfolio_transactions_job.rb b/app/jobs/csv_exportable/export_portfolio_transactions_job.rb new file mode 100644 index 000000000..4bbe4a34f --- /dev/null +++ b/app/jobs/csv_exportable/export_portfolio_transactions_job.rb @@ -0,0 +1,121 @@ +module CsvExportable + class ExportPortfolioTransactionsJob < BaseExporter + def perform(args) + tx_ids = AccountBook.joins(:ckb_transaction). + select("DISTINCT ON (ckb_transaction_id) account_books.*"). + where(address_id: args[:address_ids]). + order(ckb_transaction_id: :asc). + limit(5000) + + if args[:start_date].present? + start_date = BigDecimal(args[:start_date]) + tx_ids = tx_ids.where("ckb_transactions.block_timestamp >= ?", start_date) + end + + if args[:end_date].present? + end_date = BigDecimal(args[:end_date]) + tx_ids = tx_ids.where("ckb_transactions.block_timestamp <= ?", end_date) + end + + if args[:start_number].present? + tx_ids = tx_ids.where("ckb_transactions.block_number >= ?", args[:start_number]) + end + + if args[:end_number].present? + tx_ids = tx_ids.where("ckb_transactions.block_number <= ?", args[:end_number]) + end + + ckb_transactions = CkbTransaction.where(id: tx_ids.pluck(:ckb_transaction_id)) + + rows = [] + ckb_transactions.find_in_batches(batch_size: 500, order: :desc) do |transactions| + tx_ids = transactions.pluck(:id) + inputs = CellOutput.where(consumed_by_id: tx_ids, address_id: args[:address_ids]) + outputs = CellOutput.where(ckb_transaction_id: tx_ids, address_id: args[:address_ids]) + + transactions.each do |transaction| + tx_inputs = inputs.select { |input| input.consumed_by_id == transaction.id }.sort_by(&:id) + tx_outputs = outputs.select { |output| output.ckb_transaction_id == transaction.id }.sort_by(&:id) + + row = generate_row(transaction, tx_inputs, tx_outputs) + next if row.blank? + + rows += row + end + end + + header = [ + "Txn hash", "Blockno", "UnixTimestamp", "Token", "Method", "Token In", + "Token Out", "Token Balance Change", "TxnFee(CKB)", "date(UTC)" + ] + + generate_csv(header, rows) + end + + def generate_row(transaction, inputs, outputs) + input_info = cell_infos(inputs) + output_info = cell_infos(outputs) + + datetime = datetime_utc(transaction.block_timestamp) + fee = parse_transaction_fee(transaction.transaction_fee) + + rows = [] + units = input_info.keys | output_info.keys + units.each_with_index do |unit, index| + if unit == "CKB" + data = build_ckb_data(input_info[unit], output_info[unit]) + else + data = build_udt_data(input_info[unit], output_info[unit]) + end + + display_fee = + if units.include?("CKB") + units.length == 1 || (units.length > 1 && unit == "CKB") + else + index == 0 + end + token = unit == "CKB" ? unit : parse_udt_token(input_info[unit], output_info[unit]) + + rows << [ + transaction.tx_hash, + transaction.block_number, + transaction.block_timestamp, + token, + data[:method], + data[:token_in], + data[:token_out], + data[:balance_diff], + (display_fee ? fee : "/"), + datetime + ] + end + + rows + end + + def cell_infos(outputs) + infos = Hash.new + outputs.each do |output| + cell = { capacity: output.capacity, cell_type: output.cell_type } + if output.udt? + cell.merge!(attributes_for_udt_cell(output)) + end + + unit = token_unit(cell) + unless infos[unit] + infos[unit] = cell + next + end + + info = infos[unit] + info[:capacity] += cell[:capacity] + + if (cell_udt_info = cell[:udt_info]).present? + info[:udt_info][:amount] += cell_udt_info[:amount] + end + end + + infos + end + end +end diff --git a/app/lib/api/v2/exceptions.rb b/app/lib/api/v2/exceptions.rb index 1865b61a0..2a6d1223f 100644 --- a/app/lib/api/v2/exceptions.rb +++ b/app/lib/api/v2/exceptions.rb @@ -15,7 +15,49 @@ def initialize(code:, status:, title:, detail:, href:) class TokenCollectionNotFoundError < Error def initialize - super code: 2001, status: 404, title: "Token Collection Not Found", detail: "No token collection found by given script hash or id", href: "https://nervosnetwork.github.io/ckb-explorer/public/api_doc.html" + super(code: 2001, status: 404, title: "Token Collection Not Found", detail: "No token collection found by given script hash or id", href: "") + end + end + + class AddressNotMatchEnvironmentError < Error + def initialize(ckb_net_mode) + super(code: 2022, status: 422, title: "Address is invalid", detail: "This address is not the #{ckb_net_mode} address", href: "") + end + end + + class InvalidPortfolioMessageError < Error + def initialize + super(code: 2003, status: 400, title: "portfolio message is invalid", detail: "", href: "") + end + end + + class InvalidPortfolioSignatureError < Error + def initialize + super(code: 2004, status: 400, title: "portfolio signature is invalid", detail: "", href: "") + end + end + + class UserNotExistError < Error + def initialize(detail) + super(code: 2005, status: 400, title: "user not exist", detail: detail, href: "") + end + end + + class DecodeJWTFailedError < Error + def initialize(detail) + super(code: 2006, status: 400, title: "decode JWT failed", detail: detail, href: "") + end + end + + class PortfolioLatestDiscrepancyError < Error + def initialize(detail) + super(code: 2007, status: 400, title: "portfolio has not synchronized the latest addresses", detail: "", href: "") + end + end + + class SyncPortfolioAddressesError < Error + def initialize + super(code: 2008, status: 400, title: "sync portfolio addresses failed", detail: "", href: "") end end end diff --git a/app/models/concerns/uuidable.rb b/app/models/concerns/uuidable.rb new file mode 100644 index 000000000..d70f54389 --- /dev/null +++ b/app/models/concerns/uuidable.rb @@ -0,0 +1,15 @@ +module Uuidable + extend ActiveSupport::Concern + + included do + before_validation do + if new_record? && uuid.blank? + begin + uuid = SecureRandom.uuid + end while self.class.where(uuid:).exists? + + write_attribute(:uuid, uuid) + end + end + end +end diff --git a/app/models/portfolio.rb b/app/models/portfolio.rb new file mode 100644 index 000000000..eddd1332d --- /dev/null +++ b/app/models/portfolio.rb @@ -0,0 +1,29 @@ +class Portfolio < ApplicationRecord + belongs_to :user + belongs_to :address + + def self.sync_addresses(user, address_hashes) + transaction do + portfolio_attributes = [] + address_hashes.each do |address_hash| + address = Address.find_or_create_by_address_hash(address_hash) + portfolio_attributes << { user_id: user.id, address_id: address.id } + end + + Portfolio.upsert_all(portfolio_attributes, unique_by: [:user_id, :address_id]) + end + end +end + +# == Schema Information +# +# Table name: portfolios +# +# id :bigint not null, primary key +# user_id :bigint +# address_id :bigint +# +# Indexes +# +# index_portfolios_on_user_id_and_address_id (user_id,address_id) UNIQUE +# diff --git a/app/models/user.rb b/app/models/user.rb new file mode 100644 index 000000000..439283f24 --- /dev/null +++ b/app/models/user.rb @@ -0,0 +1,23 @@ +class User < ApplicationRecord + include Uuidable + + has_many :portfolios + has_many :addresses, through: :portfolios +end + +# == Schema Information +# +# Table name: users +# +# id :bigint not null, primary key +# uuid :string(36) +# identifier :string +# name :string +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_users_on_identifier (identifier) UNIQUE +# index_users_on_uuid (uuid) UNIQUE +# diff --git a/app/services/portfolio_signature_verifier.rb b/app/services/portfolio_signature_verifier.rb new file mode 100644 index 000000000..43605d575 --- /dev/null +++ b/app/services/portfolio_signature_verifier.rb @@ -0,0 +1,48 @@ +require "ecdsa" + +class PortfolioSignatureVerifier + attr_reader :address, :message, :signature, :pub_key + + def initialize(address, message, signature, pub_key) + @address = address + @message = message + @signature = signature + @pub_key = pub_key + end + + def verified? + @pub_key ||= recover_from_signature + puts "pub_key: #{@pub_key}" + + encode_address = blake160_address + puts "encode_address: #{encode_address}" + address == encode_address + end + + def blake160_address + CkbUtils.generate_address(default_lock_script) + end + + def recover_from_signature + group = ECDSA::Group::Secp256k1 + msg_buffer = [message[2..]].pack("H*") + sig_buffer = [signature[2..]].pack("H*") + + sign = ECDSA::Signature.new( + sig_buffer.slice(0..31).unpack1("H*").to_i(16), + sig_buffer.slice(32..63).unpack1("H*").to_i(16) + ) + + points = ECDSA.recover_public_key(group, msg_buffer, sign) + ECDSA::Format::PointOctetString.encode(points.first, compression: true).unpack1("H*") + end + + def default_lock_script + puts "blake160: #{public_key_blake160}" + CKB::Types::Script.generate_lock(public_key_blake160, CKB::SystemCodeHash::SECP256K1_BLAKE160_SIGHASH_ALL_TYPE_HASH) + end + + def public_key_blake160 + CKB::Key.blake160(pub_key) + end +end diff --git a/app/services/portfolios/udt_accounts_statistic.rb b/app/services/portfolios/udt_accounts_statistic.rb new file mode 100644 index 000000000..fd6126de3 --- /dev/null +++ b/app/services/portfolios/udt_accounts_statistic.rb @@ -0,0 +1,88 @@ +module Portfolios + class UdtAccountsStatistic + attr_reader :user + + def initialize(user) + @user = user + end + + def sudt_accounts(published = true) + udt_accounts = UdtAccount.sudt.where(address_id: user.address_ids, published: published) + grouped_accounts = + udt_accounts.group_by(&:type_hash).transform_values do |accounts| + total_amount = accounts.reduce(0) { |sum, account| sum + account.amount } + { + symbol: accounts[0].symbol, + decimal: accounts[0].decimal.to_s, + amount: total_amount.to_s, + type_hash: accounts[0].type_hash, + udt_icon_file: accounts[0].udt_icon_file, + udt_type: accounts[0].udt_type, + display_name: accounts[0].display_name, + uan: accounts[0].uan + } + end + + grouped_accounts.values + end + + def nft_accounts + udt_accounts = UdtAccount.published.where(address_id: user.address_ids). + where.not(cell_type: "sudt") + return [] if udt_accounts.blank? + + udt_accounts.map do |udt_account| + case udt_account.udt_type + when "m_nft_token" + ts = TypeScript.find_by script_hash: udt_account.type_hash + if ts + i = TokenItem.includes(collection: :type_script).find_by type_script_id: ts.id + coll = i&.collection + end + { + symbol: udt_account.full_name, + decimal: udt_account.decimal.to_s, + amount: udt_account.amount.to_s, + type_hash: udt_account.type_hash, + collection: { + type_hash: coll&.type_script&.script_hash + }, + udt_icon_file: udt_account.udt_icon_file, + udt_type: udt_account.udt_type + } + when "nrc_721_token" + udt = udt_account.udt + factory_cell = udt_account.udt.nrc_factory_cell + coll = factory_cell&.token_collection + { + symbol: factory_cell&.symbol || udt.symbol, + amount: udt_account.nft_token_id.to_s, + type_hash: udt_account.type_hash, + collection: { + type_hash: coll&.type_script&.script_hash + }, + udt_icon_file: "#{udt_account.udt.nrc_factory_cell&.base_token_uri}/#{udt_account.nft_token_id}", + udt_type: udt_account.udt_type + } + when "spore_cell" + ts = TypeScript.where(script_hash: udt_account.type_hash).first + if ts + data = ts.cell_outputs.order(id: :desc).first.data + i = TokenItem.includes(collection: :type_script).find_by type_script_id: ts.id + coll = i&.collection + end + { + symbol: udt_account.full_name, + amount: udt_account.nft_token_id.to_s, + type_hash: udt_account.type_hash, + collection: { + type_hash: coll&.type_script&.script_hash + }, + udt_icon_file: data, + udt_type: udt_account.udt_type + } + end + end + end + end +end diff --git a/app/utils/portfolio_utils.rb b/app/utils/portfolio_utils.rb new file mode 100644 index 000000000..4750cf29a --- /dev/null +++ b/app/utils/portfolio_utils.rb @@ -0,0 +1,12 @@ +module PortfolioUtils + class << self + def generate_jwt(payload) + payload[:exp] ||= Time.current.to_i + ENV["AUTH_ACCESS_EXPIRE"].to_i + JWT.encode(payload, ENV["SECRET_KEY_BASE"], "HS256") + end + + def decode_jwt(jwt) + JWT.decode jwt, ENV["SECRET_KEY_BASE"], true, { algorithm: "HS256" } + end + end +end diff --git a/app/views/api/v2/portfolio/statistics/index.json.jbuilder b/app/views/api/v2/portfolio/statistics/index.json.jbuilder new file mode 100644 index 000000000..4e561a520 --- /dev/null +++ b/app/views/api/v2/portfolio/statistics/index.json.jbuilder @@ -0,0 +1,8 @@ +json.data do + json.portfolio_statistic do + json.capacity @portfolio_statistic.capacity.to_s + json.occupied_capacity @portfolio_statistic.occupied_capacity.to_s + json.dao_deposit @portfolio_statistic.dao_deposit.to_s + json.dao_compensation (@portfolio_statistic.interest.to_i + @portfolio_statistic.unclaimed_compensation.to_i).to_s + end +end diff --git a/config/routes/v2.rb b/config/routes/v2.rb index cdb46cf20..c48e88196 100644 --- a/config/routes/v2.rb +++ b/config/routes/v2.rb @@ -52,7 +52,6 @@ get :download_csv end end - end resources :dao_events, only: [:index] @@ -76,5 +75,18 @@ get :transaction_fees end end + + namespace :portfolio do + resources :sessions, only: :create + resource :user, only: :update + resources :statistics, only: :index + resources :addresses, only: :create + resources :udt_accounts, only: :index + resources :ckb_transactions, only: :index do + collection do + get :download_csv + end + end + end end end diff --git a/db/migrate/20231017023456_create_users.rb b/db/migrate/20231017023456_create_users.rb new file mode 100644 index 000000000..e9fed0c69 --- /dev/null +++ b/db/migrate/20231017023456_create_users.rb @@ -0,0 +1,14 @@ +class CreateUsers < ActiveRecord::Migration[7.0] + def change + create_table :users do |t| + t.string :uuid, limit: 36 + t.string :identifier + t.string :name + + t.timestamps + end + + add_index :users, :uuid, unique: true + add_index :users, :identifier, unique: true + end +end diff --git a/db/migrate/20231017024100_create_portfolios.rb b/db/migrate/20231017024100_create_portfolios.rb new file mode 100644 index 000000000..33bec8ad5 --- /dev/null +++ b/db/migrate/20231017024100_create_portfolios.rb @@ -0,0 +1,10 @@ +class CreatePortfolios < ActiveRecord::Migration[7.0] + def change + create_table :portfolios do |t| + t.bigint :user_id + t.bigint :address_id + end + + add_index :portfolios, [:user_id, :address_id], unique: true + end +end diff --git a/db/structure.sql b/db/structure.sql index a0e72caf9..3dde2e76c 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -1741,6 +1741,36 @@ CREATE SEQUENCE public.pool_transaction_entries_id_seq ALTER SEQUENCE public.pool_transaction_entries_id_seq OWNED BY public.pool_transaction_entries.id; +-- +-- Name: portfolios; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.portfolios ( + id bigint NOT NULL, + user_id bigint, + address_id bigint +); + + +-- +-- Name: portfolios_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.portfolios_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: portfolios_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.portfolios_id_seq OWNED BY public.portfolios.id; + + -- -- Name: referring_cells; Type: TABLE; Schema: public; Owner: - -- @@ -2376,6 +2406,39 @@ CREATE SEQUENCE public.uncle_blocks_id_seq ALTER SEQUENCE public.uncle_blocks_id_seq OWNED BY public.uncle_blocks.id; +-- +-- Name: users; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.users ( + id bigint NOT NULL, + uuid character varying(36), + identifier character varying, + name character varying, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL +); + + +-- +-- Name: users_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.users_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: users_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.users_id_seq OWNED BY public.users.id; + + -- -- Name: witnesses; Type: TABLE; Schema: public; Owner: - -- @@ -2638,6 +2701,13 @@ ALTER TABLE ONLY public.omiga_inscription_infos ALTER COLUMN id SET DEFAULT next ALTER TABLE ONLY public.pool_transaction_entries ALTER COLUMN id SET DEFAULT nextval('public.pool_transaction_entries_id_seq'::regclass); +-- +-- Name: portfolios id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.portfolios ALTER COLUMN id SET DEFAULT nextval('public.portfolios_id_seq'::regclass); + + -- -- Name: referring_cells id; Type: DEFAULT; Schema: public; Owner: - -- @@ -2750,6 +2820,13 @@ ALTER TABLE ONLY public.udts ALTER COLUMN id SET DEFAULT nextval('public.udts_id ALTER TABLE ONLY public.uncle_blocks ALTER COLUMN id SET DEFAULT nextval('public.uncle_blocks_id_seq'::regclass); +-- +-- Name: users id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.users ALTER COLUMN id SET DEFAULT nextval('public.users_id_seq'::regclass); + + -- -- Name: witnesses id; Type: DEFAULT; Schema: public; Owner: - -- @@ -3077,6 +3154,14 @@ ALTER TABLE ONLY public.pool_transaction_entries ADD CONSTRAINT pool_transaction_entries_pkey PRIMARY KEY (id); +-- +-- Name: portfolios portfolios_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.portfolios + ADD CONSTRAINT portfolios_pkey PRIMARY KEY (id); + + -- -- Name: referring_cells referring_cells_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -3253,6 +3338,14 @@ ALTER TABLE ONLY public.udts ADD CONSTRAINT unique_type_hash UNIQUE (type_hash); +-- +-- Name: users users_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.users + ADD CONSTRAINT users_pkey PRIMARY KEY (id); + + -- -- Name: witnesses witnesses_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -4031,6 +4124,20 @@ CREATE INDEX index_pool_transaction_entries_on_tx_hash ON public.pool_transactio CREATE INDEX index_pool_transaction_entries_on_tx_status ON public.pool_transaction_entries USING btree (tx_status); +-- +-- Name: index_portfolios_on_user_id_and_address_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_portfolios_on_user_id_and_address_id ON public.portfolios USING btree (user_id, address_id); + + +-- +-- Name: index_referring_cells_on_cell_output_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_referring_cells_on_cell_output_id ON public.referring_cells USING btree (cell_output_id); + + -- -- Name: index_referring_cells_on_contract_id_and_cell_output_id; Type: INDEX; Schema: public; Owner: - -- @@ -4276,6 +4383,20 @@ CREATE UNIQUE INDEX index_uncle_blocks_on_block_hash_and_block_id ON public.uncl CREATE INDEX index_uncle_blocks_on_block_id ON public.uncle_blocks USING btree (block_id); +-- +-- Name: index_users_on_identifier; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_users_on_identifier ON public.users USING btree (identifier); + + +-- +-- Name: index_users_on_uuid; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_users_on_uuid ON public.users USING btree (uuid); + + -- -- Name: index_witnesses_on_ckb_transaction_id; Type: INDEX; Schema: public; Owner: - -- @@ -4816,6 +4937,8 @@ INSERT INTO "schema_migrations" (version) VALUES ('20230913091025'), ('20230914120928'), ('20230918033957'), +('20231017023456'), +('20231017024100'), ('20231017074221'), ('20231218082938'), ('20240107100346'), diff --git a/test/controllers/api/v1/udts_controller_test.rb b/test/controllers/api/v1/udts_controller_test.rb index 9c25c3fed..d5ec1df77 100644 --- a/test/controllers/api/v1/udts_controller_test.rb +++ b/test/controllers/api/v1/udts_controller_test.rb @@ -377,7 +377,7 @@ class UdtsControllerTest < ActionDispatch::IntegrationTest assert_equal 415, response.status end - test "should respond with error object when call download csv Content-Type is wrong" do + test "should respond with error object when Call download csv Content-Type is wrong" do udt = create(:udt, published: true) error_object = Api::V1::Exceptions::InvalidContentTypeError.new response_json = RequestErrorSerializer.new([error_object], @@ -401,7 +401,7 @@ class UdtsControllerTest < ActionDispatch::IntegrationTest assert_equal 406, response.status end - test "should respond with error object when call download csv Accept is wrong" do + test "should respond with error object when Call download csv Accept is wrong" do udt = create(:udt, published: true) error_object = Api::V1::Exceptions::InvalidAcceptError.new response_json = RequestErrorSerializer.new([error_object], diff --git a/test/controllers/api/v2/portfolio/addresses_controller_test.rb b/test/controllers/api/v2/portfolio/addresses_controller_test.rb new file mode 100644 index 000000000..d63b5065a --- /dev/null +++ b/test/controllers/api/v2/portfolio/addresses_controller_test.rb @@ -0,0 +1,38 @@ +require "test_helper" + +module Api + module V2 + module Portfolio + class AddressesControllerTest < ActionDispatch::IntegrationTest + setup do + ENV["AUTH_ACCESS_EXPIRE"] = "1296000" + ENV["SECRET_KEY_BASE"] = SecureRandom.hex(32) + end + + test "should respond with error object when address parsed failed" do + error_object = Api::V2::Exceptions::SyncPortfolioAddressesError.new + response_json = RequestErrorSerializer.new([error_object], message: error_object.title).serialized_json + + user = create(:user) + jwt = PortfolioUtils.generate_jwt({ uuid: user.uuid }) + + post api_v2_portfolio_addresses_url(addresses: ["test"]), headers: { "Authorization": jwt } + + assert_equal response_json, response.body + end + + test "should return 204 status code when sync addresses success" do + user = create(:user) + jwt = PortfolioUtils.generate_jwt({ uuid: user.uuid }) + address = "ckt1q3w9q60tppt7l3j7r09qcp7lxnp3vcanvgha8pmvsa3jplykxn323k5v49yzmvm0q0kfqw0hk0kyal6z32nwjvcqqr7qyzq8yqtec2wj" + + assert_difference -> { user.reload.portfolios.count }, 1 do + post api_v2_portfolio_addresses_url(addresses: [address]), headers: { "Authorization": jwt } + end + + assert_response :no_content + end + end + end + end +end diff --git a/test/controllers/api/v2/portfolio/sessions_controller_test.rb b/test/controllers/api/v2/portfolio/sessions_controller_test.rb new file mode 100644 index 000000000..e42b58b2d --- /dev/null +++ b/test/controllers/api/v2/portfolio/sessions_controller_test.rb @@ -0,0 +1,66 @@ +require "test_helper" + +module Api + module V2 + module Portfolio + class SessionsControllerTest < ActionDispatch::IntegrationTest + setup do + ENV["CKB_NET_MODE"] = "testnet" + ENV["AUTH_ACCESS_EXPIRE"] = "1296000" + ENV["SECRET_KEY_BASE"] = SecureRandom.hex(32) + @message = "0x95e919c41e1ae7593730097e9bb1185787b046ae9f47b4a10ff4e22f9c3e3eab" + @signature = "0x1e94db61cff452639cf7dd991cf0c856923dcf74af24b6f575b91479ad2c8ef40769812d1cf1fd1a15d2f6cb9ef3d91260ef27e65e1f9be399887e9a5447786301" + @address = "ckt1qzda0cr08m85hc8jlnfp3zer7xulejywt49kt2rr0vthywaa50xwsqfkcv576ccddnn4quf2ga65xee2m26h7nq4sds0r" + end + + test "should respond with error object when address does not match the testnet" do + error_object = Api::V2::Exceptions::AddressNotMatchEnvironmentError.new(ENV["CKB_NET_MODE"]) + response_json = RequestErrorSerializer.new([error_object], message: error_object.title).serialized_json + + post api_v2_portfolio_sessions_url, + params: { address: "test", message: @message, signature: @signature } + + assert_equal response_json, response.body + end + + test "should respond with error object when message is wrong" do + error_object = Api::V2::Exceptions::InvalidPortfolioMessageError.new + response_json = RequestErrorSerializer.new([error_object], message: error_object.title).serialized_json + + post api_v2_portfolio_sessions_url, + params: { address: @address, message: "test", signature: @signature } + + assert_equal response_json, response.body + end + + test "should respond with error object when signature is wrong" do + error_object = Api::V2::Exceptions::InvalidPortfolioSignatureError.new + response_json = RequestErrorSerializer.new([error_object], message: error_object.title).serialized_json + + post api_v2_portfolio_sessions_url, + params: { address: @address, message: @message, signature: "test" } + + assert_equal response_json, response.body + end + + test "should create user when user is not exists" do + assert_difference -> { User.count }, 1 do + post api_v2_portfolio_sessions_url, + params: { address: @address, message: @message, signature: @signature } + end + end + + test "should return jwt when user sign in" do + post api_v2_portfolio_sessions_url, + params: { address: @address, message: @message, signature: @signature } + + access_expire = Time.current.to_i + ENV["AUTH_ACCESS_EXPIRE"].to_i + payload = { uuid: User.find_by(identifier: @address).uuid, exp: access_expire } + jwt = PortfolioUtils.generate_jwt(payload) + + assert_equal jwt, json["jwt"] + end + end + end + end +end diff --git a/test/controllers/api/v2/portfolio/statistics_controller_test.rb b/test/controllers/api/v2/portfolio/statistics_controller_test.rb new file mode 100644 index 000000000..7acf09600 --- /dev/null +++ b/test/controllers/api/v2/portfolio/statistics_controller_test.rb @@ -0,0 +1,47 @@ +require "test_helper" + +module Api + module V2 + module Portfolio + class StatisticsControllerTest < ActionDispatch::IntegrationTest + setup do + ENV["AUTH_ACCESS_EXPIRE"] = "1296000" + ENV["SECRET_KEY_BASE"] = SecureRandom.hex(32) + @user = create(:user) + @jwt = PortfolioUtils.generate_jwt({ uuid: @user.uuid }) + end + + test "should respond with error object when addresses inconsistencies detected" do + address = "ckt1q3w9q60tppt7l3j7r09qcp7lxnp3vcanvgha8pmvsa3jplykxn323k5v49yzmvm0q0kfqw0hk0kyal6z32nwjvcqqr7qyzq8yqtec2wj" + error_object = Api::V2::Exceptions::PortfolioLatestDiscrepancyError.new(address) + response_json = RequestErrorSerializer.new([error_object], message: error_object.title).serialized_json + + get api_v2_portfolio_statistics_url(latest_address: address), + headers: { "Authorization": @jwt } + assert_equal response_json, response.body + end + + test "should return statistic when address inconsistencies resolved" do + address_hash = "ckt1q3w9q60tppt7l3j7r09qcp7lxnp3vcanvgha8pmvsa3jplykxn323k5v49yzmvm0q0kfqw0hk0kyal6z32nwjvcqqr7qyzq8yqtec2wj" + address = create(:address, address_hash: address_hash) + create(:portfolio, user: @user, address: address) + + response_json = { + data: { + balance: address.balance.to_s, + balance_occupied: address.balance_occupied.to_s, + dao_deposit: address.dao_deposit.to_s, + interest: address.interest.to_s, + dao_compensation: (address.interest.to_i + address.unclaimed_compensation.to_i).to_s + } + }.as_json + + get api_v2_portfolio_statistics_url(latest_address: address_hash), headers: { + "Authorization": @jwt, "Accept": "application/json" + } + assert_equal response_json, json + end + end + end + end +end diff --git a/test/controllers/api/v2/portfolio/users_controller_test.rb b/test/controllers/api/v2/portfolio/users_controller_test.rb new file mode 100644 index 000000000..ab488c539 --- /dev/null +++ b/test/controllers/api/v2/portfolio/users_controller_test.rb @@ -0,0 +1,56 @@ +require "test_helper" + +module Api + module V2 + module Portfolio + class UsersControllerTest < ActionDispatch::IntegrationTest + setup do + ENV["AUTH_ACCESS_EXPIRE"] = "1296000" + ENV["SECRET_KEY_BASE"] = SecureRandom.hex(32) + end + + test "should respond with error object when use not exists" do + error_object = Api::V2::Exceptions::UserNotExistError.new("validate jwt") + response_json = RequestErrorSerializer.new([error_object], message: error_object.title).serialized_json + + jwt = PortfolioUtils.generate_jwt({ uuid: "test" }) + put api_v2_portfolio_user_url, headers: { "Authorization": jwt } + + assert_equal response_json, response.body + end + + test "should respond with error object when jwt expired" do + user = create(:user) + + error_object = Api::V2::Exceptions::DecodeJWTFailedError.new("Signature has expired") + response_json = RequestErrorSerializer.new([error_object], message: error_object.title).serialized_json + + exp = Time.current.to_i - ENV["AUTH_ACCESS_EXPIRE"].to_i + jwt = PortfolioUtils.generate_jwt({ uuid: user.uuid, exp: exp }) + put api_v2_portfolio_user_url, headers: { "Authorization": jwt } + + assert_equal response_json, response.body + end + + test "should respond with error object when jwt decode failed" do + error_object = Api::V2::Exceptions::DecodeJWTFailedError.new("Not enough or too many segments") + response_json = RequestErrorSerializer.new([error_object], message: error_object.title).serialized_json + + put api_v2_portfolio_user_url, headers: { "Authorization": "test" } + + assert_equal response_json, response.body + end + + test "should return 204 status code when update name success" do + user = create(:user) + jwt = PortfolioUtils.generate_jwt({ uuid: user.uuid }) + + put api_v2_portfolio_user_url(name: "Jack"), headers: { "Authorization": jwt } + + assert_equal "Jack", user.reload.name + assert_response :no_content + end + end + end + end +end diff --git a/test/factories/portfolio.rb b/test/factories/portfolio.rb new file mode 100644 index 000000000..9d14da8f0 --- /dev/null +++ b/test/factories/portfolio.rb @@ -0,0 +1,6 @@ +FactoryBot.define do + factory :portfolio do + user + address + end +end \ No newline at end of file diff --git a/test/factories/user.rb b/test/factories/user.rb new file mode 100644 index 000000000..c15e4c73b --- /dev/null +++ b/test/factories/user.rb @@ -0,0 +1,11 @@ +FactoryBot.define do + factory :user do + uuid { SecureRandom.uuid } + + identifier do + script = CKB::Types::Script.new(code_hash: Settings.secp_cell_type_hash, args: "0x#{SecureRandom.hex(20)}", + hash_type: "type") + CKB::Address.new(script).generate + end + end +end diff --git a/test/models/portfolio_test.rb b/test/models/portfolio_test.rb new file mode 100644 index 000000000..2f0dddc72 --- /dev/null +++ b/test/models/portfolio_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class PortfolioTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/user_test.rb b/test/models/user_test.rb new file mode 100644 index 000000000..5c07f4900 --- /dev/null +++ b/test/models/user_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class UserTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/services/portfolio_signature_verifier_test.rb b/test/services/portfolio_signature_verifier_test.rb new file mode 100644 index 000000000..d0ed9bae5 --- /dev/null +++ b/test/services/portfolio_signature_verifier_test.rb @@ -0,0 +1,33 @@ +require "test_helper" + +class PortfolioSignatureVerifierTest < ActiveSupport::TestCase + setup do + @sign_info = { + message: "0x95e919c41e1ae7593730097e9bb1185787b046ae9f47b4a10ff4e22f9c3e3eab", + signature: "0x1e94db61cff452639cf7dd991cf0c856923dcf74af24b6f575b91479ad2c8ef40769812d1cf1fd1a15d2f6cb9ef3d91260ef27e65e1f9be399887e9a5447786301", + pub_key: "0x024a501efd328e062c8675f2365970728c859c592beeefd6be8ead3d901330bc01", + blake160: "0x36c329ed630d6ce750712a477543672adab57f4c" + } + end + + test ".recover_from_signature should return encode pub_key" do + verifier = PortfolioSignatureVerifier.new(nil, @sign_info[:message], @sign_info[:signature], nil) + pub_key = "0x#{verifier.recover_from_signature}" + assert_equal pub_key, @sign_info[:pub_key] + end + + test ".public_key_blake160 should return correct blake160" do + verifier = PortfolioSignatureVerifier.new(nil, @sign_info[:message], @sign_info[:signature], @sign_info[:pub_key]) + public_key_blake160 = verifier.public_key_blake160 + assert_equal public_key_blake160, @sign_info[:blake160] + end + + test ".verified? should return true with correct signature" do + ENV["CKB_NET_MODE"] = "testnet" + address = "ckt1qzda0cr08m85hc8jlnfp3zer7xulejywt49kt2rr0vthywaa50xwsqfkcv576ccddnn4quf2ga65xee2m26h7nq4sds0r" + verifier = PortfolioSignatureVerifier.new(address, @sign_info[:message], @sign_info[:signature], nil) + assert_equal true, verifier.verified? + + ENV["CKB_NET_MODE"] = "mainnet" + end +end