539 lines
14 KiB
Go
539 lines
14 KiB
Go
// Package kraken is a minimal client for the parts of the Kraken REST API
|
|||
|
|
// this app needs: public ticker/OHLC price data, and private balance/trade
|
||
|
|
// history for auto-importing purchase lots. No SDK — plain net/http plus
|
||
|
|
// stdlib crypto for request signing, per Kraken's documented spec.
|
||
|
|
package kraken
|
||
|
|
|
||
|
|
import (
|
||
|
|
"crypto/hmac"
|
||
|
|
"crypto/sha256"
|
||
|
|
"crypto/sha512"
|
||
|
|
"encoding/base64"
|
||
|
|
"encoding/json"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"net/http"
|
||
|
|
"net/url"
|
||
|
|
"sort"
|
||
|
|
"strconv"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
const baseURL = "https://api.kraken.com"
|
||
|
|
|
||
|
|
// fiatCurrencies are Kraken's supported cash currencies. Balance/Ledger
|
||
|
|
// entries for these represent cash sitting in the account, not a crypto
|
||
|
|
// position — callers exclude them from portfolio tracking.
|
||
|
|
var fiatCurrencies = map[string]bool{
|
||
|
|
"USD": true, "EUR": true, "GBP": true, "JPY": true,
|
||
|
|
"CHF": true, "CAD": true, "AUD": true,
|
||
|
|
}
|
||
|
|
|
||
|
|
// IsFiat reports whether altname (as returned by AssetAltName) is one of
|
||
|
|
// Kraken's cash currencies rather than a crypto asset.
|
||
|
|
func IsFiat(altname string) bool { return fiatCurrencies[altname] }
|
||
|
|
|
||
|
|
type Client struct {
|
||
|
|
apiKey string
|
||
|
|
apiSecret string // base64-encoded, as issued by Kraken
|
||
|
|
http *http.Client
|
||
|
|
|
||
|
|
pairs map[string]pairInfo // keyed by Kraken's pair name, e.g. XXBTZUSD
|
||
|
|
assets map[string]string // asset code -> altname, e.g. XXBT -> XBT
|
||
|
|
}
|
||
|
|
|
||
|
|
type pairInfo struct {
|
||
|
|
AltName string `json:"altname"`
|
||
|
|
Base string `json:"base"`
|
||
|
|
Quote string `json:"quote"`
|
||
|
|
}
|
||
|
|
|
||
|
|
func New(apiKey, apiSecret string) *Client {
|
||
|
|
return &Client{
|
||
|
|
apiKey: apiKey,
|
||
|
|
apiSecret: apiSecret,
|
||
|
|
http: &http.Client{Timeout: 15 * time.Second},
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
type envelope struct {
|
||
|
|
Error []string `json:"error"`
|
||
|
|
Result json.RawMessage `json:"result"`
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *Client) doPublic(path string, params url.Values) (json.RawMessage, error) {
|
||
|
|
u := baseURL + path
|
||
|
|
if params != nil {
|
||
|
|
u += "?" + params.Encode()
|
||
|
|
}
|
||
|
|
resp, err := c.http.Get(u)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
defer resp.Body.Close()
|
||
|
|
return decodeEnvelope(resp.Body)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *Client) doPrivate(path string, params url.Values) (json.RawMessage, error) {
|
||
|
|
if c.apiKey == "" || c.apiSecret == "" {
|
||
|
|
return nil, fmt.Errorf("kraken: no API credentials configured")
|
||
|
|
}
|
||
|
|
if params == nil {
|
||
|
|
params = url.Values{}
|
||
|
|
}
|
||
|
|
nonce := strconv.FormatInt(time.Now().UnixNano()/int64(time.Millisecond), 10)
|
||
|
|
params.Set("nonce", nonce)
|
||
|
|
body := params.Encode()
|
||
|
|
|
||
|
|
sign, err := c.sign(path, nonce, body)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
|
||
|
|
req, err := http.NewRequest(http.MethodPost, baseURL+path, strings.NewReader(body))
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||
|
|
req.Header.Set("API-Key", c.apiKey)
|
||
|
|
req.Header.Set("API-Sign", sign)
|
||
|
|
|
||
|
|
resp, err := c.http.Do(req)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
defer resp.Body.Close()
|
||
|
|
return decodeEnvelope(resp.Body)
|
||
|
|
}
|
||
|
|
|
||
|
|
func decodeEnvelope(r io.Reader) (json.RawMessage, error) {
|
||
|
|
var env envelope
|
||
|
|
if err := json.NewDecoder(r).Decode(&env); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
if len(env.Error) > 0 {
|
||
|
|
return nil, fmt.Errorf("kraken: %s", strings.Join(env.Error, "; "))
|
||
|
|
}
|
||
|
|
return env.Result, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// sign implements Kraken's documented API-Sign algorithm:
|
||
|
|
// HMAC-SHA512(path + SHA256(nonce + postdata), base64-decoded secret).
|
||
|
|
func (c *Client) sign(path, nonce, postData string) (string, error) {
|
||
|
|
secret, err := base64.StdEncoding.DecodeString(c.apiSecret)
|
||
|
|
if err != nil {
|
||
|
|
return "", fmt.Errorf("kraken: invalid api secret: %w", err)
|
||
|
|
}
|
||
|
|
shaSum := sha256.Sum256([]byte(nonce + postData))
|
||
|
|
mac := hmac.New(sha512.New, secret)
|
||
|
|
mac.Write([]byte(path))
|
||
|
|
mac.Write(shaSum[:])
|
||
|
|
return base64.StdEncoding.EncodeToString(mac.Sum(nil)), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- asset/pair metadata (loaded lazily, cached for process lifetime) ---
|
||
|
|
|
||
|
|
func (c *Client) loadPairs() error {
|
||
|
|
if c.pairs != nil {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
raw, err := c.doPublic("/0/public/AssetPairs", nil)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
var m map[string]pairInfo
|
||
|
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
c.pairs = m
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (c *Client) loadAssets() error {
|
||
|
|
if c.assets != nil {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
raw, err := c.doPublic("/0/public/Assets", nil)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
var m map[string]struct {
|
||
|
|
AltName string `json:"altname"`
|
||
|
|
}
|
||
|
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
out := map[string]string{}
|
||
|
|
for code, a := range m {
|
||
|
|
out[code] = a.AltName
|
||
|
|
}
|
||
|
|
c.assets = out
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// AssetAltName resolves a raw Kraken asset code (e.g. "XXBT") to its
|
||
|
|
// human-friendly ticker (e.g. "XBT"). Falls back to the raw code.
|
||
|
|
func (c *Client) AssetAltName(code string) string {
|
||
|
|
if err := c.loadAssets(); err != nil {
|
||
|
|
return code
|
||
|
|
}
|
||
|
|
if alt, ok := c.assets[code]; ok {
|
||
|
|
return alt
|
||
|
|
}
|
||
|
|
return code
|
||
|
|
}
|
||
|
|
|
||
|
|
// PairAssets resolves a Kraken pair name (as returned by TradesHistory or
|
||
|
|
// stored on a purchase, e.g. "XXBTZUSD") to its base/quote altnames (e.g.
|
||
|
|
// "XBT", "USD"). Falls back to the raw pair name if it can't be resolved.
|
||
|
|
func (c *Client) PairAssets(pair string) (base, quote string) {
|
||
|
|
if err := c.loadPairs(); err != nil {
|
||
|
|
return pair, ""
|
||
|
|
}
|
||
|
|
if info, ok := c.pairs[pair]; ok {
|
||
|
|
return c.AssetAltName(info.Base), c.AssetAltName(info.Quote)
|
||
|
|
}
|
||
|
|
return pair, ""
|
||
|
|
}
|
||
|
|
|
||
|
|
// FindPairFor returns a Kraken pair (and its quote altname) trading
|
||
|
|
// baseAsset against a common fiat — useful for pricing a currency that
|
||
|
|
// wasn't acquired through a recorded buy/sell (e.g. a staking reward or
|
||
|
|
// airdrop that only shows up in the account balance). Prefers USD, then
|
||
|
|
// EUR, then GBP, then whatever pair is first found.
|
||
|
|
func (c *Client) FindPairFor(baseAsset string) (pair, quote string) {
|
||
|
|
if err := c.loadPairs(); err != nil {
|
||
|
|
return "", ""
|
||
|
|
}
|
||
|
|
preferred := []string{"USD", "EUR", "GBP"}
|
||
|
|
var fallbackPair, fallbackQuote string
|
||
|
|
for name, info := range c.pairs {
|
||
|
|
if c.AssetAltName(info.Base) != baseAsset {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
q := c.AssetAltName(info.Quote)
|
||
|
|
for _, p := range preferred {
|
||
|
|
if q == p {
|
||
|
|
return name, q
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if fallbackPair == "" {
|
||
|
|
fallbackPair, fallbackQuote = name, q
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return fallbackPair, fallbackQuote
|
||
|
|
}
|
||
|
|
|
||
|
|
// wsSymbolOverrides covers the handful of assets where Kraken's REST
|
||
|
|
// altname (used everywhere else in this app) differs from the ticker used
|
||
|
|
// on the WebSocket v2 API — confirmed live: XBT/USD is rejected, BTC/USD
|
||
|
|
// isn't; XDG/USD is rejected, DOGE/USD isn't. Everything else matches.
|
||
|
|
var wsSymbolOverrides = map[string]string{
|
||
|
|
"XBT": "BTC",
|
||
|
|
"XDG": "DOGE",
|
||
|
|
}
|
||
|
|
|
||
|
|
// WSSymbol returns the "BASE/QUOTE" symbol Kraken's public WebSocket v2
|
||
|
|
// ticker channel expects for a given altname pair.
|
||
|
|
func WSSymbol(currency, quote string) string {
|
||
|
|
if alt, ok := wsSymbolOverrides[currency]; ok {
|
||
|
|
currency = alt
|
||
|
|
}
|
||
|
|
return currency + "/" + quote
|
||
|
|
}
|
||
|
|
|
||
|
|
// WithCredentials returns a copy of c authenticated with the given API
|
||
|
|
// key/secret, sharing the same cached pair/asset metadata (maps are
|
||
|
|
// reference types, so the copy's cache stays warm).
|
||
|
|
func (c *Client) WithCredentials(apiKey, apiSecret string) *Client {
|
||
|
|
cp := *c
|
||
|
|
cp.apiKey = apiKey
|
||
|
|
cp.apiSecret = apiSecret
|
||
|
|
return &cp
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- public: ticker ---
|
||
|
|
|
||
|
|
// Ticker returns the last traded price for one pair given as its altname
|
||
|
|
// (e.g. "XBTUSD"). Kraken keys the response by its internal pair name, not
|
||
|
|
// the altname requested, so we just take the single entry back.
|
||
|
|
func (c *Client) Ticker(altPair string) (float64, error) {
|
||
|
|
raw, err := c.doPublic("/0/public/Ticker", url.Values{"pair": {altPair}})
|
||
|
|
if err != nil {
|
||
|
|
return 0, err
|
||
|
|
}
|
||
|
|
var m map[string]struct {
|
||
|
|
C []string `json:"c"` // last trade closed [price, lot volume]
|
||
|
|
}
|
||
|
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||
|
|
return 0, err
|
||
|
|
}
|
||
|
|
for _, v := range m {
|
||
|
|
if len(v.C) > 0 {
|
||
|
|
return strconv.ParseFloat(v.C[0], 64)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return 0, fmt.Errorf("kraken: no ticker data for %s", altPair)
|
||
|
|
}
|
||
|
|
|
||
|
|
// FXRate returns how many units of `to` one unit of `from` is worth, using
|
||
|
|
// Kraken's fiat-cross tickers. Tries the direct pair, then the inverse, then
|
||
|
|
// triangulates through USD if neither exists directly.
|
||
|
|
func (c *Client) FXRate(from, to string) (float64, error) {
|
||
|
|
if from == "" || to == "" || from == to {
|
||
|
|
return 1, nil
|
||
|
|
}
|
||
|
|
if r, err := c.Ticker(from + to); err == nil && r > 0 {
|
||
|
|
return r, nil
|
||
|
|
}
|
||
|
|
if r, err := c.Ticker(to + from); err == nil && r > 0 {
|
||
|
|
return 1 / r, nil
|
||
|
|
}
|
||
|
|
if from != "USD" && to != "USD" {
|
||
|
|
r1, err1 := c.FXRate(from, "USD")
|
||
|
|
r2, err2 := c.FXRate("USD", to)
|
||
|
|
if err1 == nil && err2 == nil {
|
||
|
|
return r1 * r2, nil
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return 0, fmt.Errorf("kraken: no fx rate for %s->%s", from, to)
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- public: OHLC ---
|
||
|
|
|
||
|
|
type Candle struct {
|
||
|
|
Time time.Time
|
||
|
|
Open, High, Low, Close float64
|
||
|
|
}
|
||
|
|
|
||
|
|
// OHLC returns candles for altPair at the given interval (minutes), only
|
||
|
|
// including data since the given time.
|
||
|
|
func (c *Client) OHLC(altPair string, intervalMinutes int, since time.Time) ([]Candle, error) {
|
||
|
|
params := url.Values{
|
||
|
|
"pair": {altPair},
|
||
|
|
"interval": {strconv.Itoa(intervalMinutes)},
|
||
|
|
"since": {strconv.FormatInt(since.Unix(), 10)},
|
||
|
|
}
|
||
|
|
raw, err := c.doPublic("/0/public/OHLC", params)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
var m map[string]json.RawMessage
|
||
|
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
var candles []Candle
|
||
|
|
for key, v := range m {
|
||
|
|
if key == "last" {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
var rows [][]interface{}
|
||
|
|
if err := json.Unmarshal(v, &rows); err != nil {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
for _, row := range rows {
|
||
|
|
if len(row) < 5 {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
t, _ := toFloat(row[0])
|
||
|
|
o, _ := toFloat(row[1])
|
||
|
|
h, _ := toFloat(row[2])
|
||
|
|
l, _ := toFloat(row[3])
|
||
|
|
cl, _ := toFloat(row[4])
|
||
|
|
candles = append(candles, Candle{Time: time.Unix(int64(t), 0), Open: o, High: h, Low: l, Close: cl})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
sort.Slice(candles, func(i, j int) bool { return candles[i].Time.Before(candles[j].Time) })
|
||
|
|
return candles, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func toFloat(v interface{}) (float64, error) {
|
||
|
|
switch x := v.(type) {
|
||
|
|
case float64:
|
||
|
|
return x, nil
|
||
|
|
case string:
|
||
|
|
return strconv.ParseFloat(x, 64)
|
||
|
|
default:
|
||
|
|
return 0, fmt.Errorf("unexpected type %T", v)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- private: balance ---
|
||
|
|
|
||
|
|
type Balance struct {
|
||
|
|
Total float64 // liquid + staked combined
|
||
|
|
Staked float64 // portion of Total that's staked/bonded
|
||
|
|
}
|
||
|
|
|
||
|
|
// NormalizeStakedAsset strips Kraken's staking-variant suffix so a staked
|
||
|
|
// balance merges into its liquid currency instead of appearing as a
|
||
|
|
// separate, untracked asset — e.g. "ETH2.S" (staked ETH) and the legacy
|
||
|
|
// "ETH2" bonding token both fold into "ETH". Any other "<BASE>.<suffix>"
|
||
|
|
// variant (e.g. "DOT.S") folds into "<BASE>" the same way.
|
||
|
|
func NormalizeStakedAsset(altname string) (base string, staked bool) {
|
||
|
|
base = altname
|
||
|
|
if i := strings.Index(altname, "."); i != -1 {
|
||
|
|
base = altname[:i]
|
||
|
|
staked = true
|
||
|
|
}
|
||
|
|
if base == "ETH2" {
|
||
|
|
base = "ETH"
|
||
|
|
staked = true
|
||
|
|
}
|
||
|
|
return base, staked
|
||
|
|
}
|
||
|
|
|
||
|
|
// Balance returns current holdings keyed by asset altname (e.g. "XBT"),
|
||
|
|
// with staked/bonded variants merged into their liquid currency.
|
||
|
|
func (c *Client) Balance() (map[string]Balance, error) {
|
||
|
|
raw, err := c.doPrivate("/0/private/Balance", nil)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
var m map[string]string
|
||
|
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
out := map[string]Balance{}
|
||
|
|
for code, amtStr := range m {
|
||
|
|
amt, err := strconv.ParseFloat(amtStr, 64)
|
||
|
|
if err != nil {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
base, staked := NormalizeStakedAsset(c.AssetAltName(code))
|
||
|
|
b := out[base]
|
||
|
|
b.Total += amt
|
||
|
|
if staked {
|
||
|
|
b.Staked += amt
|
||
|
|
}
|
||
|
|
out[base] = b
|
||
|
|
}
|
||
|
|
return out, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- private: trades history ---
|
||
|
|
|
||
|
|
type Trade struct {
|
||
|
|
ID string
|
||
|
|
Pair string // Kraken pair name, e.g. XXBTZUSD
|
||
|
|
Currency string // base asset altname, e.g. XBT
|
||
|
|
Quote string // quote asset altname, e.g. USD
|
||
|
|
Type string // "buy" or "sell"
|
||
|
|
Price float64
|
||
|
|
Cost float64
|
||
|
|
Fee float64
|
||
|
|
Vol float64
|
||
|
|
Time time.Time
|
||
|
|
}
|
||
|
|
|
||
|
|
// TradesHistory returns every closed trade on the account, paginating
|
||
|
|
// through Kraken's 50-per-page limit.
|
||
|
|
func (c *Client) TradesHistory() ([]Trade, error) {
|
||
|
|
var out []Trade
|
||
|
|
offset := 0
|
||
|
|
for {
|
||
|
|
params := url.Values{"ofs": {strconv.Itoa(offset)}}
|
||
|
|
raw, err := c.doPrivate("/0/private/TradesHistory", params)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
var page struct {
|
||
|
|
Trades map[string]json.RawMessage `json:"trades"`
|
||
|
|
Count int `json:"count"`
|
||
|
|
}
|
||
|
|
if err := json.Unmarshal(raw, &page); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
for id, rawTrade := range page.Trades {
|
||
|
|
var m map[string]interface{}
|
||
|
|
if err := json.Unmarshal(rawTrade, &m); err != nil {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
t := Trade{ID: id}
|
||
|
|
t.Pair, _ = m["pair"].(string)
|
||
|
|
t.Type, _ = m["type"].(string)
|
||
|
|
if s, ok := m["price"].(string); ok {
|
||
|
|
t.Price, _ = strconv.ParseFloat(s, 64)
|
||
|
|
}
|
||
|
|
if s, ok := m["cost"].(string); ok {
|
||
|
|
t.Cost, _ = strconv.ParseFloat(s, 64)
|
||
|
|
}
|
||
|
|
if s, ok := m["fee"].(string); ok {
|
||
|
|
t.Fee, _ = strconv.ParseFloat(s, 64)
|
||
|
|
}
|
||
|
|
if s, ok := m["vol"].(string); ok {
|
||
|
|
t.Vol, _ = strconv.ParseFloat(s, 64)
|
||
|
|
}
|
||
|
|
if f, ok := m["time"].(float64); ok {
|
||
|
|
t.Time = time.Unix(int64(f), 0)
|
||
|
|
}
|
||
|
|
t.Currency, t.Quote = c.PairAssets(t.Pair)
|
||
|
|
out = append(out, t)
|
||
|
|
}
|
||
|
|
offset += len(page.Trades)
|
||
|
|
if len(page.Trades) == 0 || offset >= page.Count {
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return out, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- private: ledger (deposits/withdrawals) ---
|
||
|
|
|
||
|
|
type LedgerRow struct {
|
||
|
|
ID string
|
||
|
|
Type string // "deposit" | "withdrawal" | (trade, staking, etc — caller filters)
|
||
|
|
Currency string // asset altname, e.g. "XBT"
|
||
|
|
Amount float64
|
||
|
|
Fee float64
|
||
|
|
Time time.Time
|
||
|
|
}
|
||
|
|
|
||
|
|
// Ledgers returns every ledger entry on the account (deposits, withdrawals,
|
||
|
|
// trades, staking, ...), paginating through Kraken's 50-per-page limit.
|
||
|
|
// Callers filter Type for what they need.
|
||
|
|
func (c *Client) Ledgers() ([]LedgerRow, error) {
|
||
|
|
var out []LedgerRow
|
||
|
|
offset := 0
|
||
|
|
for {
|
||
|
|
params := url.Values{"ofs": {strconv.Itoa(offset)}}
|
||
|
|
raw, err := c.doPrivate("/0/private/Ledgers", params)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
var page struct {
|
||
|
|
Ledger map[string]json.RawMessage `json:"ledger"`
|
||
|
|
Count int `json:"count"`
|
||
|
|
}
|
||
|
|
if err := json.Unmarshal(raw, &page); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
for id, rawEntry := range page.Ledger {
|
||
|
|
var m map[string]interface{}
|
||
|
|
if err := json.Unmarshal(rawEntry, &m); err != nil {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
e := LedgerRow{ID: id}
|
||
|
|
e.Type, _ = m["type"].(string)
|
||
|
|
asset, _ := m["asset"].(string)
|
||
|
|
e.Currency, _ = NormalizeStakedAsset(c.AssetAltName(asset))
|
||
|
|
if s, ok := m["amount"].(string); ok {
|
||
|
|
e.Amount, _ = strconv.ParseFloat(s, 64)
|
||
|
|
}
|
||
|
|
if s, ok := m["fee"].(string); ok {
|
||
|
|
e.Fee, _ = strconv.ParseFloat(s, 64)
|
||
|
|
}
|
||
|
|
if f, ok := m["time"].(float64); ok {
|
||
|
|
e.Time = time.Unix(int64(f), 0)
|
||
|
|
}
|
||
|
|
out = append(out, e)
|
||
|
|
}
|
||
|
|
offset += len(page.Ledger)
|
||
|
|
if len(page.Ledger) == 0 || offset >= page.Count {
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return out, nil
|
||
|
|
}
|