Files
sure/app/models/coinstats_item/wallet_linker.rb
Ethan 3b4ab735b0 Add (beta) CoinStats Crypto Wallet Integration with Balance and Transaction Syncing (#512)
* Feat(CoinStats): Scaffold implementation, not yet functional

* Feat(CoinStats): Implement crypto wallet balance and transactions

* Feat(CoinStats): Add tests, Minor improvements

* Feat(CoinStats): Utilize bulk fetch API endpoints

* Feat(CoinStats): Migrate strings to i8n

* Feat(CoinStats): Fix error handling in wallet link modal

* Feat(CoinStats): Implement hourly provider sync job

* Feat(CoinStats): Generate docstrings

* Fix(CoinStats): Validate API Key on provider update

* Fix(Providers): Safely handle race condition in merchance creation

* Fix(CoinStats): Don't catch system signals in account processor

* Fix(CoinStats): Preload before iterating accounts

* Fix(CoinStats): Add no opener / referrer to API dashboard link

* Fix(CoinStats): Use strict matching for symbols

* Fix(CoinStats): Remove dead code in transactions importer

* Fix(CoinStats): Avoid transaction fallback ID collisions

* Fix(CoinStats): Improve Blockchains fetch error handling

* Fix(CoinStats): Enforce NOT NULL constraint for API Key schema

* Fix(CoinStats): Migrate sync status strings to i8n

* Fix(CoinStats): Use class name rather than hardcoded string

* Fix(CoinStats): Use account currency rather than hardcoded USD

* Fix(CoinStats): Migrate from standalone to Provider class

* Fix(CoinStats): Fix test failures due to string changes
2026-01-07 15:59:04 +01:00

158 lines
5.5 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
)
# 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