mirror of
https://github.com/we-promise/sure.git
synced 2026-04-13 00:57:22 +00:00
* feat: Support multiple crypto wallets with same token Allows users to import multiple wallets containing the same cryptocurrency (e.g., ETH on different wallet addresses). Changes: - Add wallet_address column to coinstats_accounts - Update uniqueness validation to include wallet_address - Extract and store wallet address in WalletLinker - Add composite unique index on [item_id, account_id, wallet_address] - Add tests for multi-wallet support and backwards compatibility Users can now have: - ETH (0xAAA...) → "Ethereum (0xAA...AA)" - ETH (0xBBB...) → "Ethereum (0xBB...BB)" Backwards compatible: existing accounts with wallet_address: nil continue to work. * style: Fix array bracket spacing in migration * chore: Update schema.rb with wallet_address column and index Add the missing wallet_address column and composite unique index to db/schema.rb for CI compatibility with db:schema:load * test: Add test for wallet deletion with same token different addresses Verifies that deleting one wallet does not affect other wallets that share the same token but have different addresses. Addresses review comment from @EthanC via @jjmata --------- Signed-off-by: Juan José Mata <juanjo.mata@gmail.com> Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
159 lines
5.6 KiB
Ruby
159 lines
5.6 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
# Links a cryptocurrency wallet to CoinStats by fetching token balances
|
|
# and creating corresponding accounts for each token found.
|
|
class CoinstatsItem::WalletLinker
|
|
attr_reader :coinstats_item, :address, :blockchain
|
|
|
|
Result = Struct.new(:success?, :created_count, :errors, keyword_init: true)
|
|
|
|
# @param coinstats_item [CoinstatsItem] Parent item with API credentials
|
|
# @param address [String] Wallet address to link
|
|
# @param blockchain [String] Blockchain network identifier
|
|
def initialize(coinstats_item, address:, blockchain:)
|
|
@coinstats_item = coinstats_item
|
|
@address = address
|
|
@blockchain = blockchain
|
|
end
|
|
|
|
# Fetches wallet balances and creates accounts for each token.
|
|
# @return [Result] Success status, created count, and any errors
|
|
def link
|
|
balance_data = fetch_balance_data
|
|
tokens = normalize_tokens(balance_data)
|
|
|
|
return Result.new(success?: false, created_count: 0, errors: [ "No tokens found for wallet" ]) if tokens.empty?
|
|
|
|
created_count = 0
|
|
errors = []
|
|
|
|
tokens.each do |token_data|
|
|
result = create_account_from_token(token_data)
|
|
if result[:success]
|
|
created_count += 1
|
|
else
|
|
errors << result[:error]
|
|
end
|
|
end
|
|
|
|
# Trigger a sync if we created any accounts
|
|
coinstats_item.sync_later if created_count > 0
|
|
|
|
Result.new(success?: created_count > 0, created_count: created_count, errors: errors)
|
|
end
|
|
|
|
private
|
|
|
|
# Fetches balance data for this wallet from CoinStats API.
|
|
# @return [Array<Hash>] Token balances for the wallet
|
|
def fetch_balance_data
|
|
provider = Provider::Coinstats.new(coinstats_item.api_key)
|
|
wallets_param = "#{blockchain}:#{address}"
|
|
response = provider.get_wallet_balances(wallets_param)
|
|
|
|
return [] unless response.success?
|
|
|
|
provider.extract_wallet_balance(response.data, address, blockchain)
|
|
end
|
|
|
|
# Normalizes various balance data formats to an array of tokens.
|
|
# @param balance_data [Array, Hash, Object] Raw balance response
|
|
# @return [Array<Hash>] Normalized array of token data
|
|
def normalize_tokens(balance_data)
|
|
if balance_data.is_a?(Array)
|
|
balance_data
|
|
elsif balance_data.is_a?(Hash)
|
|
balance_data[:result] || balance_data[:tokens] || [ balance_data ]
|
|
elsif balance_data.present?
|
|
[ balance_data ]
|
|
else
|
|
[]
|
|
end
|
|
end
|
|
|
|
# Creates a CoinstatsAccount and linked Account for a token.
|
|
# @param token_data [Hash] Token balance data from API
|
|
# @return [Hash] Result with :success and optional :error
|
|
def create_account_from_token(token_data)
|
|
token = token_data.with_indifferent_access
|
|
account_name = build_account_name(token)
|
|
current_balance = calculate_balance(token)
|
|
token_id = (token[:coinId] || token[:id])&.to_s
|
|
|
|
ActiveRecord::Base.transaction do
|
|
coinstats_account = coinstats_item.coinstats_accounts.create!(
|
|
name: account_name,
|
|
currency: "USD",
|
|
current_balance: current_balance,
|
|
account_id: token_id,
|
|
wallet_address: address
|
|
)
|
|
|
|
# Store wallet metadata for future syncs
|
|
snapshot = build_snapshot(token, current_balance)
|
|
coinstats_account.upsert_coinstats_snapshot!(snapshot)
|
|
|
|
account = coinstats_item.family.accounts.create!(
|
|
accountable: Crypto.new,
|
|
name: account_name,
|
|
balance: current_balance,
|
|
cash_balance: current_balance,
|
|
currency: coinstats_account.currency,
|
|
status: "active"
|
|
)
|
|
|
|
AccountProvider.create!(account: account, provider: coinstats_account)
|
|
|
|
{ success: true }
|
|
end
|
|
rescue ActiveRecord::RecordInvalid, ActiveRecord::RecordNotSaved => e
|
|
Rails.logger.error("CoinstatsItem::WalletLinker - Failed to create account: #{e.message}")
|
|
{ success: false, error: "Failed to create #{account_name || 'account'}: #{e.message}" }
|
|
rescue => e
|
|
Rails.logger.error("CoinstatsItem::WalletLinker - Unexpected error: #{e.class} - #{e.message}")
|
|
{ success: false, error: "Unexpected error: #{e.message}" }
|
|
end
|
|
|
|
# Builds a display name for the account from token and address.
|
|
# @param token [Hash] Token data with :name
|
|
# @return [String] Human-readable account name
|
|
def build_account_name(token)
|
|
token_name = token[:name].to_s.strip
|
|
truncated_address = address.present? ? "#{address.first(4)}...#{address.last(4)}" : nil
|
|
|
|
if token_name.present? && truncated_address.present?
|
|
"#{token_name} (#{truncated_address})"
|
|
elsif token_name.present?
|
|
token_name
|
|
elsif truncated_address.present?
|
|
"#{blockchain.capitalize} (#{truncated_address})"
|
|
else
|
|
"Crypto Wallet"
|
|
end
|
|
end
|
|
|
|
# Calculates USD balance from token amount and price.
|
|
# @param token [Hash] Token data with :amount/:balance and :price
|
|
# @return [Float] Balance in USD
|
|
def calculate_balance(token)
|
|
amount = token[:amount] || token[:balance] || token[:current_balance] || 0
|
|
price = token[:price] || 0
|
|
(amount.to_f * price.to_f)
|
|
end
|
|
|
|
# Builds snapshot hash for storing in CoinstatsAccount.
|
|
# @param token [Hash] Token data from API
|
|
# @param current_balance [Float] Calculated USD balance
|
|
# @return [Hash] Snapshot with balance, address, and metadata
|
|
def build_snapshot(token, current_balance)
|
|
token.to_h.merge(
|
|
id: (token[:coinId] || token[:id])&.to_s,
|
|
balance: current_balance,
|
|
currency: "USD",
|
|
address: address,
|
|
blockchain: blockchain,
|
|
institution_logo: token[:imgUrl]
|
|
)
|
|
end
|
|
end
|