Files
sure/app/models/provider/kraken.rb
ghost d329a4f69d feat(kraken): import deposits, withdrawals, staking & fees via Ledgers API (#2451)
* feat(kraken): fetch and import Ledgers API for deposits, withdrawals, staking, fees

Closes #2450

Kraken TradesHistory only returns spot buy/sell trades. The Ledgers API
(/0/private/Ledgers) covers deposits, withdrawals, staking rewards, Earn
income, and standalone fees — everything that was missing from syncs.

Changes:
- Provider::Kraken#get_ledgers — new method forwarding start/type/offset params
- KrakenItem::Importer#fetch_ledgers — paginated fetch (up to 200 pages) with
  graceful fallback if the API key lacks Query Ledger Entries permission
- Importer#upsert_kraken_account — stores "ledgers" alongside "trades" in
  raw_transactions_payload
- KrakenAccount::LedgerProcessor — new class; maps each supported ledger type
  (deposit, withdrawal, staking, earn, fee) to a Transaction entry with the
  correct investment_activity_label, kind, and sign convention; skips trade/
  transfer/margin types to avoid double-counting with TradesHistory
- KrakenAccount::Processor#process — calls LedgerProcessor after process_trades
- Multi-currency: fiat amounts converted via ExchangeRate (non-USD fiat bridged
  through USD); crypto amounts use the spot price cached in raw_payload["assets"]
  with a price_missing flag when no price is available
- Dedup guard: external_id "kraken_ledger_<id>" + source "kraken" prevents
  re-importing on repeated syncs

* fix(kraken): correct sign convention, fee inclusion, and earn subtype filtering

- Sign convention: deposits/staking/earn → negative (inflow), withdrawals/fees
  → positive (outflow), matching Sure's global convention (inflow is negative)
- Fee inclusion: use (amount - fee).abs as abs_impact so withdrawal fees are
  counted in the total outflow rather than discarded
- Earn subtypes: skip allocation/deallocation ledger entries (internal fund
  movements); only import rewardallocation/bonusallocation as Interest income
- DebugLogEntry: replace Rails.logger.warn/error with DebugLogEntry.capture
  throughout LedgerProcessor and the Ledgers permission fallback in Importer,
  so support-relevant incidents surface in /settings/debug
- Importer test: stub get_ledgers in setup so existing tests do not error
  on the new fetch_ledgers call

* fix(kraken): route duplicate ledger-id warning through DebugLogEntry

* perf(kraken): batch ledger idempotency check; strengthen tests

Address review feedback (jjmata):

- N+1: LedgerProcessor#process_ledger_entry ran `account.entries.exists?(...)`
  per ledger entry (up to ~10k per sync). Load the existing Kraken external IDs
  once into a Set and test membership in memory (newly created IDs are added so
  the same run stays idempotent) — same pattern as #2452.
- Tests: the idempotency test now asserts the first pass actually creates the
  entry (assert_difference) before asserting the second is a no-op; add a guard
  asserting the second (all-skipped) pass issues a single bulk external_id pluck,
  not one query per entry.

No behavior change to imported entries.

* perf(kraken): scope ledger idempotency pluck to kraken_ledger_ prefix

Only load existing ledger external IDs (not trade entries) into the idempotency
Set, matching the reviewed approach. No behavior change.
2026-06-30 07:35:14 +02:00

163 lines
4.6 KiB
Ruby

# frozen_string_literal: true
class Provider::Kraken
include HTTParty
extend SslConfigurable
class Error < StandardError; end
class AuthenticationError < Error; end
class PermissionError < Error; end
class RateLimitError < Error; end
class NonceError < Error; end
class OTPRequiredError < Error; end
class ApiError < Error; end
BASE_URL = "https://api.kraken.com"
PRIVATE_PREFIX = "/0/private"
PUBLIC_PREFIX = "/0/public"
base_uri BASE_URL
default_options.merge!({ timeout: 30 }.merge(httparty_ssl_options))
attr_reader :api_key, :api_secret
def initialize(api_key:, api_secret:, nonce_generator: nil)
@api_key = api_key # pipelock:ignore user-supplied Kraken credential kept in memory for signed requests
@api_secret = api_secret # pipelock:ignore user-supplied Kraken credential kept in memory for signed requests
@nonce_generator = nonce_generator || -> { Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond).to_s }
end
def get_api_key_info
private_post("GetApiKeyInfo")
end
def get_extended_balance
private_post("BalanceEx")
end
def get_trades_history(start: nil, offset: nil)
params = {}
params["start"] = start.to_i.to_s if start.present?
params["ofs"] = offset.to_i.to_s if offset.present?
private_post("TradesHistory", params)
end
def get_ledgers(start: nil, type: nil, offset: nil)
params = {}
params["start"] = start.to_i.to_s if start.present?
params["type"] = type.to_s if type.present?
params["ofs"] = offset.to_i.to_s if offset.present?
private_post("Ledgers", params)
end
def get_asset_info(asset: nil)
params = {}
params["asset"] = asset if asset.present?
public_get("Assets", params)
end
def get_asset_pairs(pair: nil)
params = {}
params["pair"] = pair if pair.present?
public_get("AssetPairs", params)
end
def get_ticker(pair)
public_get("Ticker", "pair" => pair)
end
def get_ohlc(pair, interval: 1440, since: nil)
params = { "pair" => pair, "interval" => interval.to_s }
params["since"] = since.to_i.to_s if since.present?
public_get("OHLC", params)
end
private
attr_reader :nonce_generator
def public_get(method, params = {})
response = self.class.get("#{PUBLIC_PREFIX}/#{method}", query: params)
handle_response(response)
end
def private_post(method, params = {})
path = "#{PRIVATE_PREFIX}/#{method}"
request_params = { "nonce" => nonce_generator.call.to_s }.merge(stringify_params(params))
body = URI.encode_www_form(request_params)
response = self.class.post(
path,
body: body,
headers: auth_headers(path, request_params).merge("Content-Type" => "application/x-www-form-urlencoded")
)
handle_response(response)
end
def stringify_params(params)
params.each_with_object({}) { |(key, value), hash| hash[key.to_s] = value.to_s }
end
def auth_headers(path, params)
{
"API-Key" => api_key,
"API-Sign" => sign(path, params)
}
end
def sign(path, params)
encoded_payload = URI.encode_www_form(params)
nonce = params.fetch("nonce").to_s
digest = OpenSSL::Digest::SHA256.digest(nonce + encoded_payload)
hmac = OpenSSL::HMAC.digest("sha512", Base64.decode64(api_secret), path + digest)
Base64.strict_encode64(hmac)
end
def handle_response(response)
parsed = response.parsed_response
unless response.code.between?(200, 299)
raise ApiError, "Kraken API request failed: #{response.code}"
end
unless parsed.is_a?(Hash)
raise ApiError, "Malformed Kraken API response"
end
unless parsed.key?("error")
raise ApiError, "Malformed Kraken API response: missing error"
end
errors = Array(parsed["error"]).reject(&:blank?)
raise classified_error(errors) if errors.any?
unless parsed.key?("result")
raise ApiError, "Malformed Kraken API response: missing result"
end
parsed["result"]
end
def classified_error(errors)
message = errors.join(", ")
case message
when /Invalid key|Invalid signature|Temporary lockout/i
AuthenticationError.new(message)
when /Invalid nonce/i
NonceError.new(message)
when /Permission denied|Invalid permissions/i
PermissionError.new(message)
when /Rate limit exceeded|Too many requests|limit exceeded|Throttled/i
RateLimitError.new(message)
when /otp|2fa|two.factor/i
OTPRequiredError.new(message)
else
ApiError.new(message)
end
end
end